diff --git a/Cargo.lock b/Cargo.lock index 87e7f48..8d9281a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -29,6 +58,33 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android-activity" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee91c0c2905bae44f84bfa4e044536541df26b7703fd0888deeb9060fcc44289" +dependencies = [ + "android-properties", + "bitflags 2.11.0", + "cc", + "cesu8", + "jni 0.21.1", + "jni-sys 0.3.1", + "libc", + "log", + "ndk 0.8.0", + "ndk-context", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -38,18 +94,126 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2 0.6.4", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.37.3+1.3.251" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +dependencies = [ + "libloading 0.7.4", +] + [[package]] name = "askama_escape" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df27b8d5ddb458c5fb1bbc1ce172d4a38c614a97d550b0ac89003897fb01de4" +[[package]] +name = "ass-core" +version = "0.1.1" +dependencies = [ + "ahash", + "bitflags 2.11.0", + "bumpalo", + "criterion", + "getrandom 0.3.4", + "hashbrown 0.14.5", + "serde", + "thiserror 1.0.69", + "wide", +] + +[[package]] +name = "ass-renderer" +version = "0.1.0" +dependencies = [ + "ahash", + "ash", + "ass-core", + "bumpalo", + "bytemuck", + "egui", + "egui-wgpu", + "egui-winit", + "fontdb", + "getrandom 0.3.4", + "image 0.24.9", + "metal", + "pkg-config", + "pollster 0.3.0", + "pretty_assertions", + "rayon", + "rfd 0.13.0", + "rustybuzz", + "serde", + "serde_json", + "smallvec", + "softbuffer", + "thiserror 1.0.69", + "tiny-skia", + "trait-variant", + "ttf-parser 0.20.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu", + "winit", +] + [[package]] name = "async-compression" version = "0.4.41" @@ -310,7 +474,7 @@ dependencies = [ "bitflags 2.11.0", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -319,6 +483,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -349,13 +534,32 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7" +dependencies = [ + "objc-sys", +] + +[[package]] +name = "block2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" +dependencies = [ + "block-sys", + "objc2 0.4.1", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.4", ] [[package]] @@ -396,7 +600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", ] [[package]] @@ -410,6 +614,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "byteorder" @@ -457,6 +675,63 @@ dependencies = [ "system-deps", ] +[[package]] +name = "calloop" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba7adb4dd5aa98e5553510223000e7148f621165ec5f9acd7113f6ca4995298" +dependencies = [ + "bitflags 2.11.0", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.11.0", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" +dependencies = [ + "calloop 0.12.4", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbor4ii" version = "0.2.14" @@ -473,6 +748,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -518,6 +795,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -600,6 +883,40 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cobs" version = "0.3.0" @@ -634,10 +951,57 @@ dependencies = [ "bitflags 2.11.0", "block", "core-foundation 0.10.1", - "core-graphics-types", + "core-graphics-types 0.2.0", "objc", ] +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "combine" version = "4.6.7" @@ -665,6 +1029,15 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.3" @@ -864,6 +1237,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "libc", +] + [[package]] name = "core-graphics" version = "0.24.0" @@ -872,7 +1258,7 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", - "core-graphics-types", + "core-graphics-types 0.2.0", "foreign-types 0.5.0", "libc", ] @@ -885,11 +1271,22 @@ checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", - "core-graphics-types", + "core-graphics-types 0.2.0", "foreign-types 0.5.0", "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.2.0" @@ -928,6 +1325,42 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -943,6 +1376,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1004,6 +1456,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ctor-lite" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e162d0c2e2068eb736b71e5597eff0b9944e6b973cd9f37b6a288ab9bf20e300" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1031,6 +1495,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "d3d12" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" +dependencies = [ + "bitflags 2.11.0", + "libloading 0.8.9", + "winapi", +] + [[package]] name = "darling" version = "0.21.3" @@ -1222,6 +1697,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -1280,9 +1761,9 @@ dependencies = [ "infer", "jni 0.21.1", "js-sys", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "percent-encoding", "thiserror 2.0.18", "tokio", @@ -1398,14 +1879,14 @@ dependencies = [ "lazy-js-bundle 0.7.4", "libc", "muda", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "objc", "objc_id", "percent-encoding", "rand 0.9.2", - "rfd", + "rfd 0.17.2", "rustc-hash 2.1.2", "serde", "serde_json", @@ -1418,7 +1899,7 @@ dependencies = [ "tracing", "tray-icon", "tungstenite 0.28.0", - "webbrowser", + "webbrowser 1.2.0", "wry", ] @@ -1963,6 +2444,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" @@ -1970,9 +2457,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -1986,6 +2473,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading 0.8.9", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -2018,12 +2514,58 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + [[package]] name = "dtoa" version = "1.0.11" @@ -2059,6 +2601,15 @@ dependencies = [ "spki", ] +[[package]] +name = "ecolor" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20930a432bbd57a6d55e07976089708d4893f3d556cf42a0d79e9e321fa73b10" +dependencies = [ + "bytemuck", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -2084,6 +2635,51 @@ dependencies = [ "zeroize", ] +[[package]] +name = "egui" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "584c5d1bf9a67b25778a3323af222dbe1a1feb532190e103901187f92c7fe29a" +dependencies = [ + "ahash", + "epaint", + "log", + "nohash-hasher", +] + +[[package]] +name = "egui-wgpu" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469ff65843f88a702b731a1532b7d03b0e8e96d283e70f3a22b0e06c46cb9b37" +dependencies = [ + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "thiserror 1.0.69", + "type-map", + "web-time 0.2.4", + "wgpu", +] + +[[package]] +name = "egui-winit" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e3da0cbe020f341450c599b35b92de4af7b00abde85624fd16f09c885573609" +dependencies = [ + "arboard", + "egui", + "log", + "raw-window-handle 0.6.2", + "smithay-clipboard", + "web-time 0.2.4", + "webbrowser 0.8.15", + "winit", +] + [[package]] name = "either" version = "1.15.0" @@ -2111,6 +2707,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "emath" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c3a552cfca14630702449d35f41c84a0d15963273771c6059175a803620f3f" +dependencies = [ + "bytemuck", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -2171,6 +2776,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "epaint" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b381f8b149657a4acf837095351839f32cd5c4aec1817fc4df84e18d76334176" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "log", + "nohash-hasher", + "parking_lot", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2187,6 +2808,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "euclid" version = "0.22.14" @@ -2197,6 +2824,21 @@ dependencies = [ "serde", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2308,6 +2950,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -2619,7 +3284,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -2674,6 +3339,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -2706,6 +3381,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glib" version = "0.18.5" @@ -2767,7 +3453,7 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2", + "objc2 0.6.4", "objc2-app-kit", "once_cell", "thiserror 2.0.18", @@ -2878,6 +3564,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +dependencies = [ + "gl_generator", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2889,6 +3596,58 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "winapi", + "windows 0.52.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" +dependencies = [ + "bitflags 2.11.0", + "gpu-descriptor-types", + "hashbrown 0.14.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "group" version = "0.13.0" @@ -2996,6 +3755,10 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] [[package]] name = "hashbrown" @@ -3019,6 +3782,21 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.11.0", + "com", + "libc", + "libloading 0.8.9", + "thiserror 1.0.69", + "widestring", + "winapi", +] + [[package]] name = "headers" version = "0.4.1" @@ -3081,6 +3859,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hickory-proto" version = "0.24.4" @@ -3144,6 +3928,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.27.0" @@ -3307,7 +4100,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -3319,6 +4112,17 @@ dependencies = [ "cc", ] +[[package]] +name = "icrate" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" +dependencies = [ + "block2 0.3.0", + "dispatch", + "objc2 0.4.1", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -3434,6 +4238,24 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-traits", + "png", + "qoi", + "tiff", +] + [[package]] name = "image" version = "0.25.10" @@ -3531,6 +4353,26 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3568,7 +4410,7 @@ dependencies = [ "serde", "serde_html_form", "serde_json", - "smol_str", + "smol_str 0.3.6", "thiserror 2.0.18", "tokio", "trait-variant", @@ -3630,7 +4472,7 @@ dependencies = [ "serde_ipld_dagcbor", "serde_json", "signature", - "smol_str", + "smol_str 0.3.6", "spin 0.10.0", "thiserror 2.0.18", "tokio", @@ -3728,7 +4570,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "smol_str", + "smol_str 0.3.6", "thiserror 2.0.18", "tokio", "trait-variant", @@ -3831,6 +4673,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "jose-b64" version = "0.1.2" @@ -3867,6 +4719,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.94" @@ -3904,6 +4765,23 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3943,6 +4821,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -4005,7 +4889,10 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -4033,6 +4920,18 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4169,7 +5068,7 @@ dependencies = [ "manganis-core", "manganis-macro", "ndk-context", - "objc2", + "objc2 0.6.4", "thiserror 2.0.18", ] @@ -4296,7 +5195,7 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" dependencies = [ - "rustix", + "rustix 1.1.4", ] [[package]] @@ -4317,6 +5216,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metal" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-graphics-types 0.1.3", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + [[package]] name = "miette" version = "7.6.0" @@ -4376,7 +5290,7 @@ dependencies = [ "smallvec", "tagptr", "triomphe", - "web-time", + "web-time 1.1.0", ] [[package]] @@ -4427,7 +5341,7 @@ dependencies = [ "gtk", "keyboard-types", "libxdo", - "objc2", + "objc2 0.6.4", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", @@ -4489,7 +5403,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more 1.0.0", "futures-buffered", "futures-lite", @@ -4501,7 +5415,27 @@ dependencies = [ "tokio-util", "wasm-bindgen", "wasm-bindgen-futures", - "web-time", + "web-time 1.1.0", +] + +[[package]] +name = "naga" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" +dependencies = [ + "bit-set", + "bitflags 2.11.0", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "rustc-hash 1.1.0", + "spirv", + "termcolor", + "thiserror 1.0.69", + "unicode-xid", ] [[package]] @@ -4521,6 +5455,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "raw-window-handle 0.6.2", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -4530,7 +5479,7 @@ dependencies = [ "bitflags 2.11.0", "jni-sys 0.3.1", "log", - "ndk-sys", + "ndk-sys 0.6.0+11769913", "num_enum", "raw-window-handle 0.6.2", "thiserror 1.0.69", @@ -4542,6 +5491,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4563,6 +5521,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -4676,12 +5640,40 @@ dependencies = [ ] [[package]] -name = "objc" -version = "0.2.7" +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" dependencies = [ - "malloc_buf", + "objc-sys", + "objc2-encode 3.0.0", ] [[package]] @@ -4690,7 +5682,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "objc2-encode", + "objc2-encode 4.1.0", "objc2-exception-helper", ] @@ -4701,9 +5693,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", - "block2", - "objc2", + "block2 0.6.2", + "objc2 0.6.4", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -4715,7 +5708,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -4725,9 +5718,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.0", + "dispatch2", + "objc2 0.6.4", "objc2-core-foundation", + "objc2-io-surface", ] +[[package]] +name = "objc2-encode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4750,9 +5752,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", - "block2", - "objc2", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -4762,7 +5787,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.0", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation", ] @@ -4774,13 +5799,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ "bitflags 2.11.0", - "block2", - "objc2", + "block2 0.6.2", + "objc2 0.6.4", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", ] +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + [[package]] name = "objc_id" version = "0.1.1" @@ -4796,6 +5830,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl" version = "0.10.76" @@ -4846,6 +5886,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "orbclient" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59aed3b33578edcfa1bc96a321d590d31832b6ad55a26f0313362ce687e9abd6" +dependencies = [ + "libc", + "libredox", +] + [[package]] name = "ouroboros" version = "0.18.5" @@ -4870,6 +5920,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + [[package]] name = "oxilangtag" version = "0.1.5" @@ -4952,11 +6011,17 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5170,6 +6235,40 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.17.16" @@ -5183,6 +6282,26 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + [[package]] name = "pollster" version = "0.4.0" @@ -5238,6 +6357,22 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -5338,6 +6473,12 @@ dependencies = [ "yansi", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + [[package]] name = "psl-types" version = "2.0.11" @@ -5360,6 +6501,24 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5367,7 +6526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -5377,7 +6536,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", - "web-time", + "web-time 1.1.0", ] [[package]] @@ -5398,7 +6557,7 @@ dependencies = [ "thiserror 2.0.18", "tinyvec", "tracing", - "web-time", + "web-time 1.1.0", ] [[package]] @@ -5407,7 +6566,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -5546,6 +6705,12 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -5558,6 +6723,35 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5567,6 +6761,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5633,6 +6836,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.12.28" @@ -5696,23 +6905,46 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfd" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0d8ab342bcc5436e04d3a4c1e09e17d74958bfaddf8d5fad6f85607df0f994f" +dependencies = [ + "block", + "dispatch", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc", + "objc-foundation", + "objc_id", + "raw-window-handle 0.5.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.48.0", +] + [[package]] name = "rfd" version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "block2", + "block2 0.6.2", "dispatch2", "js-sys", "libc", "log", - "objc2", + "objc2 0.6.4", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", "percent-encoding", - "pollster", + "pollster 0.4.0", "raw-window-handle 0.6.2", "wasm-bindgen", "wasm-bindgen-futures", @@ -5734,6 +6966,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.9.10" @@ -5775,6 +7013,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5784,7 +7035,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -5820,7 +7071,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ - "web-time", + "web-time 1.1.0", "zeroize", ] @@ -5841,12 +7092,37 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee8fe2a8461a0854a37101fe7a1b13998d0cfa987e43248e81d2a5f4570f6fa" +dependencies = [ + "bitflags 1.3.2", + "bytemuck", + "smallvec", + "ttf-parser 0.20.0", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -5877,6 +7153,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sctk-adwaita" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.18.1", + "tiny-skia", +] + [[package]] name = "sec1" version = "0.7.3" @@ -6263,40 +7552,112 @@ dependencies = [ ] [[package]] -name = "sledgehammer_bindgen_macro" -version = "0.6.5" +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" +dependencies = [ + "bitflags 2.11.0", + "calloop 0.12.4", + "calloop-wayland-source 0.2.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols 0.31.2", + "wayland-protocols-wlr 0.2.0", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "quote", - "syn 2.0.117", + "bitflags 2.11.0", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols 0.32.12", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr 0.3.12", + "wayland-scanner", + "xkeysym", ] [[package]] -name = "sledgehammer_utils" -version = "0.3.1" +name = "smithay-clipboard" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" dependencies = [ - "rustc-hash 1.1.0", + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", ] [[package]] -name = "slotmap" -version = "1.1.1" +name = "smol_str" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" dependencies = [ "serde", - "version_check", ] -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - [[package]] name = "smol_str" version = "0.3.6" @@ -6317,6 +7678,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "drm", + "fastrand", + "js-sys", + "memmap2", + "ndk 0.9.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle 0.6.2", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + [[package]] name = "soup3" version = "0.5.0" @@ -6358,6 +7751,15 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "spki" version = "0.7.3" @@ -6380,6 +7782,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "string_cache" version = "0.8.9" @@ -6534,7 +7942,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.25.0", "crossbeam-channel", @@ -6547,10 +7955,10 @@ dependencies = [ "jni 0.21.1", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", - "objc2", + "ndk-sys 0.6.0+11769913", + "objc2 0.6.4", "objc2-app-kit", "objc2-foundation", "once_cell", @@ -6560,8 +7968,8 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", "windows-version", "x11-dl", ] @@ -6592,7 +8000,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -6607,6 +8015,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6656,6 +8073,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.47" @@ -6690,6 +8118,45 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" +dependencies = [ + "as-raw-xcb-connection", + "ctor-lite", + "libloading 0.8.9", + "pkg-config", + "tracing", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -6700,6 +8167,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -7061,7 +8538,7 @@ dependencies = [ "dirs", "libappindicator", "muda", - "objc2", + "objc2 0.6.4", "objc2-app-kit", "objc2-core-foundation", "objc2-core-graphics", @@ -7084,6 +8561,18 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.24.0" @@ -7140,6 +8629,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + [[package]] name = "typenum" version = "1.19.0" @@ -7152,12 +8650,36 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d12260fb92d52f9008be7e4bca09f584780eb2266dc8fecc6a192bec561694" + +[[package]] +name = "unicode-ccc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2520efa644f8268dce4dcd3050eaa7fc044fca03961e9998ac7e2e92b77cf1" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -7267,7 +8789,7 @@ dependencies = [ "gloo-worker", "hex", "humansize", - "image", + "image 0.25.10", "insta", "jacquard", "jacquard-common", @@ -7323,7 +8845,11 @@ dependencies = [ name = "vodplace-player-worker" version = "0.1.0" dependencies = [ + "ass-core", + "ass-renderer", "console_error_panic_hook", + "fontdb", + "getrandom 0.3.4", "js-sys", "postcard", "serde", @@ -7461,83 +8987,243 @@ dependencies = [ ] [[package]] -name = "wasm-encoder" -version = "0.244.0" +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-split-macro" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd50dcf6d6a3477aefcca05dc16ee82a467c8b7e05fd00bb65826fe2220370b" +dependencies = [ + "base16", + "digest", + "proc-macro2", + "quote", + "sha2", + "syn 2.0.117", +] + +[[package]] +name = "wasm-splitter" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaa042ae07cfa7c55f362e394d4ecb71210438875d1bc9f9ae6e5bf918c129" +dependencies = [ + "async-once-cell", + "wasm-split-macro", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasp-hls" +version = "0.1.0" +dependencies = [ + "serde-hls", + "thiserror 2.0.18", + "wasm-bindgen", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.0", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "leb128fmt", - "wasmparser", + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.12", + "wayland-scanner", ] [[package]] -name = "wasm-metadata" -version = "0.244.0" +name = "wayland-protocols-misc" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.12", + "wayland-scanner", ] [[package]] -name = "wasm-split-macro" -version = "0.7.4" +name = "wayland-protocols-plasma" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd50dcf6d6a3477aefcca05dc16ee82a467c8b7e05fd00bb65826fe2220370b" +checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" dependencies = [ - "base16", - "digest", - "proc-macro2", - "quote", - "sha2", - "syn 2.0.117", + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.31.2", + "wayland-scanner", ] [[package]] -name = "wasm-splitter" -version = "0.7.4" +name = "wayland-protocols-wlr" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaa042ae07cfa7c55f362e394d4ecb71210438875d1bc9f9ae6e5bf918c129" +checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" dependencies = [ - "async-once-cell", - "wasm-split-macro", + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.31.2", + "wayland-scanner", ] [[package]] -name = "wasm-streams" -version = "0.4.2" +name = "wayland-protocols-wlr" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.12", + "wayland-scanner", ] [[package]] -name = "wasmparser" -version = "0.244.0" +name = "wayland-scanner" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap", - "semver", + "proc-macro2", + "quick-xml", + "quote", ] [[package]] -name = "wasp-hls" -version = "0.1.0" +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ - "serde-hls", - "thiserror 2.0.18", - "wasm-bindgen", + "dlib", + "log", + "once_cell", + "pkg-config", ] [[package]] @@ -7550,6 +9236,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -7560,6 +9256,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b" +dependencies = [ + "core-foundation 0.9.4", + "home", + "jni 0.21.1", + "log", + "ndk-context", + "objc", + "raw-window-handle 0.5.2", + "url", + "web-sys", +] + [[package]] name = "webbrowser" version = "1.2.0" @@ -7570,7 +9283,7 @@ dependencies = [ "jni 0.22.4", "log", "ndk-context", - "objc2", + "objc2 0.6.4", "objc2-foundation", "url", "web-sys", @@ -7649,8 +9362,8 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", "windows-implement", "windows-interface", ] @@ -7673,8 +9386,131 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" +dependencies = [ + "arrayvec", + "cfg-if", + "cfg_aliases 0.1.1", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.11.0", + "cfg_aliases 0.1.1", + "codespan-reporting", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle 0.6.2", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "web-sys", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.11.0", + "block", + "cfg_aliases 0.1.1", + "core-graphics-types 0.1.3", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle 0.6.2", + "renderdoc-sys", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 1.0.69", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" +dependencies = [ + "bitflags 2.11.0", + "js-sys", + "web-sys", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", ] [[package]] @@ -7714,6 +9550,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -7721,7 +9567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.61.2", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -7733,7 +9579,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core", + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -7755,7 +9610,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", "windows-threading", ] @@ -7800,7 +9655,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", ] @@ -7860,6 +9715,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -7911,6 +9775,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -7968,6 +9847,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7986,6 +9871,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8004,6 +9895,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8034,6 +9931,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8052,6 +9955,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8070,6 +9979,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8088,6 +10003,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8100,6 +10021,54 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winit" +version = "0.29.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.0", + "bytemuck", + "calloop 0.12.4", + "cfg_aliases 0.1.1", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "cursor-icon", + "icrate", + "js-sys", + "libc", + "log", + "memmap2", + "ndk 0.8.0", + "ndk-sys 0.5.0+25.2.9519653", + "objc2 0.4.1", + "once_cell", + "orbclient", + "percent-encoding", + "raw-window-handle 0.6.2", + "redox_syscall 0.3.5", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.18.1", + "smol_str 0.2.2", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.31.2", + "wayland-protocols-plasma", + "web-sys", + "web-time 0.2.4", + "windows-sys 0.48.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "0.5.40" @@ -8228,7 +10197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" dependencies = [ "base64", - "block2", + "block2 0.6.2", "cookie", "crossbeam-channel", "dirs", @@ -8241,8 +10210,8 @@ dependencies = [ "jni 0.21.1", "kuchikiki", "libc", - "ndk", - "objc2", + "ndk 0.9.0", + "objc2 0.6.4", "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", @@ -8259,8 +10228,8 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", "windows-version", ] @@ -8291,8 +10260,12 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ + "as-raw-xcb-connection", "gethostname", - "rustix", + "libc", + "libloading 0.8.9", + "once_cell", + "rustix 1.1.4", "x11rb-protocol", ] @@ -8302,12 +10275,37 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.0", + "dlib", + "log", + "once_cell", + "xkeysym", +] + [[package]] name = "xkeysym" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "xml5ever" version = "0.18.1" @@ -8449,6 +10447,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/crates/ass-core/Cargo.toml b/crates/ass-core/Cargo.toml new file mode 100644 index 0000000..180625d --- /dev/null +++ b/crates/ass-core/Cargo.toml @@ -0,0 +1,85 @@ +[package] +name = "ass-core" +version = "0.1.1" +edition = "2021" +license = "MIT" +readme = "README.md" +description = "High-performance ASS subtitle format parser and analyzer" + +[lib] +path = "lib.rs" + +[dependencies] +thiserror = "1.0.63" +ahash = { version = "0.8.11", default-features = false, features = ["std"] } +bitflags = "2.4.1" + +hashbrown = { version = "0.14", optional = true, features = ["ahash"] } +bumpalo = { version = "3.14", optional = true } +wide = { version = "0.7", optional = true } +serde = { version = "1.0", optional = true, features = ["derive"] } +criterion = { version = "0.5", optional = true, features = ["html_reports"] } + +# wasm32 requires getrandom's wasm_js feature (pulled in transitively via ahash) +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.3", features = ["wasm_js"] } + +[features] +default = ["full"] + +minimal = [ + "nostd", + "analysis", + "plugins", + "stream", +] + +full = [ + "std", + "analysis", + "plugins", + "stream", + "simd", + "arena", + "unicode-wrap", + "serde", +] + +std = [] +nostd = ["dep:hashbrown"] + +analysis = [] +plugins = [] +stream = [] + +unicode-wrap = [] +serde = ["dep:serde"] + +simd = ["dep:wide"] +simd-full = ["simd"] +arena = ["dep:bumpalo"] + +benches = ["dep:criterion"] + +[lints.rust] +unused_variables = "deny" +unused_imports = "deny" +unused_mut = "deny" +unreachable_code = "warn" + +[lints.clippy] +all = "deny" +pedantic = "warn" +nursery = "warn" +cargo = "warn" +negative_feature_names = "allow" +module_name_repetitions = "allow" +similar_names = "allow" +too_many_lines = "allow" +inefficient_to_string = "deny" +string_add = "deny" +string_add_assign = "deny" +cast_ptr_alignment = "deny" +fn_to_numeric_cast_any = "deny" +missing_docs_in_private_items = "warn" +undocumented_unsafe_blocks = "deny" diff --git a/crates/ass-core/README.md b/crates/ass-core/README.md new file mode 100644 index 0000000..2b9b9c4 --- /dev/null +++ b/crates/ass-core/README.md @@ -0,0 +1,183 @@ +# ass-core + +[![Crates.io](https://img.shields.io/crates/v/ass-core.svg)](https://crates.io/crates/ass-core) +[![Documentation](https://docs.rs/ass-core/badge.svg)](https://docs.rs/ass-core) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) + +High-performance ASS (Advanced SubStation Alpha) subtitle format parser and analyzer for Rust. + +## Features + +- **Zero-copy parsing**: Efficient lifetime-generic AST with minimal allocations +- **Full ASS v4+ support**: Complete compatibility with libass and Aegisub +- **Advanced analysis**: Script linting, style resolution, and performance optimization +- **SIMD acceleration**: Optional SIMD-optimized parsing for maximum performance +- **nostd compatible**: Works in embedded and WASM environments +- **Streaming support**: Parse large files incrementally with bounded memory + +## Performance Targets + +- **Parse speed**: <5ms for typical 1KB scripts +- **Memory usage**: <1.1x input size via zero-copy design +- **Peak memory**: <10MB for large subtitle files +- **Incremental parsing**: <5ms for typical edit operations (infrastructure ready) + +## Quick Start + +Add to your `Cargo.toml`: + +```toml +[dependencies] +ass-core = { version = "0.1", features = ["analysis"] } +``` + +Basic usage: + +```rust +use ass_core::parser::Script; + +let script_text = r#" +[Script Info] +Title: Example +ScriptType: v4.00+ + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello World! +"#; + +let script = Script::parse(script_text)?; +println!("Parsed {} sections", script.sections().len()); +``` + +## Advanced Usage + +### Script Analysis + +```rust +use ass_core::analysis::ScriptAnalysis; + +let analysis = ScriptAnalysis::analyze(&script)?; +println!("Found {} dialogue events", analysis.dialogue_info().len()); +``` + +### Linting + +```rust +use ass_core::analysis::linting::{lint_script, LintConfig}; + +let config = LintConfig::default(); +let issues = lint_script(&script, &config)?; +for issue in issues { + println!("{}: {}", issue.severity(), issue.message()); +} +``` + +### Streaming Parser + +```rust +use ass_core::parser::streaming::StreamingParser; + +let mut parser = StreamingParser::new(); +for chunk in file_chunks { + parser.feed_chunk(chunk)?; +} +let result = parser.finalize()?; +``` + +## Feature Flags + +- `std` (default): Standard library support +- `nostd`: nostd compatibility +- `analysis`: Enable script analysis and linting +- `plugins`: Extension registry support +- `simd`: SIMD-accelerated parsing +- `arena`: Arena allocation for improved performance +- `stream`: Streaming/chunked input support +- `serde`: Serialization support +- `benches`: Enable benchmarking infrastructure + +## Architecture + +The crate is organized into several modules: + +- `parser`: Core ASS parsing with zero-copy AST +- `analysis`: Deep script analysis and optimization detection +- `linting`: Comprehensive rule-based script validation +- `utils`: Common utilities and error types + +## Benchmarking + +Performance benchmarks can be run with: + +```bash +# Run all benchmarks +cargo bench --features=benches + +# Run specific benchmarks +cargo bench --features=benches parser_benchmarks +cargo bench --features=benches incremental_benchmarks +``` + +The benchmarking suite includes: +- **Parser benchmarks**: Full parsing performance across complexity levels +- **Incremental benchmarks**: Infrastructure for validating incremental parsing +- **Real-world scenarios**: Testing against realistic subtitle patterns (anime, movies, karaoke, signs, educational content) +- **Large-scale anime**: Benchmarks for 30-50MB+ files with 10k-100k events (BD releases, complex OVAs) +- **Editor simulation**: Realistic editing patterns (typing, backspace, copy-paste) +- **Memory benchmarks**: Zero-copy efficiency and memory ratio validation +- **Memory profiling**: Simple tool to measure actual memory usage + +### Memory Profiling + +To analyze memory usage without external tools: + +```bash +# Run memory benchmarks +cargo bench --features=benches memory_benchmarks + +# Run simple memory profiler +cargo run --features=benches --bin memory-profile +``` + +The memory profiler shows: +- Input size vs actual memory usage +- Memory ratio (target: <1.1x) +- Parse time and section counts +- Pass/fail status for memory targets + +### Benchmark Configuration + +For faster benchmark runs (e.g., in CI): + +```bash +# Quick mode - fewer samples, shorter measurement time +QUICK_BENCH=1 cargo bench --features=benches + +# Run specific benchmark group +cargo bench --features=benches incremental_parsing + +# Increase time limit for complex benchmarks +cargo bench --features=benches -- --measurement-time 15 +``` + +If you see warnings about unable to complete samples: +- This is normal for complex benchmarks +- The results are still valid +- Use `QUICK_BENCH=1` for faster iteration +- Or increase measurement time as suggested + +## Compatibility + +- **ASS v4.00+**: Full support including advanced features +- **SSA v4**: Legacy compatibility mode +- **libass extensions**: Support for renderer-specific features +- **Aegisub compatibility**: Full support for Aegisub-specific features + +## License + +Licensed under the [MIT license](../../LICENSE). + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](../../../CONTRIBUTING.md) for guidelines. diff --git a/crates/ass-core/analysis/events/dialogue_info.rs b/crates/ass-core/analysis/events/dialogue_info.rs new file mode 100644 index 0000000..f26cdf6 --- /dev/null +++ b/crates/ass-core/analysis/events/dialogue_info.rs @@ -0,0 +1,736 @@ +//! Dialogue event analysis and information extraction +//! +//! Provides comprehensive analysis of individual ASS dialogue events including +//! timing validation, complexity scoring, and performance impact assessment. +//! +//! # Features +//! +//! - Zero-copy analysis using lifetime-generic design +//! - Animation complexity scoring (0-10 scale) +//! - Performance impact categorization +//! - Timing relationship detection between events +//! - Duration calculations in multiple formats +//! +//! # Performance +//! +//! - Target: <1ms per event analysis +//! - Memory: Minimal allocations via zero-copy spans +//! - Caching: Results stored for repeated queries + +use crate::{ + analysis::events::{ + scoring::{ + calculate_animation_score, calculate_complexity_score, get_performance_impact, + PerformanceImpact, + }, + text_analysis::TextAnalysis, + }, + parser::Event, + utils::{parse_ass_time, CoreError}, + Result, +}; + +#[cfg(feature = "plugins")] +use crate::plugin::ExtensionRegistry; + +/// Timing relationship between two dialogue events +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TimingRelation { + /// Events have no temporal overlap + NoOverlap, + /// Events partially overlap in time + PartialOverlap, + /// One event completely contains the other + FullOverlap, + /// Events have identical timing + Identical, +} + +/// Comprehensive analysis results for a dialogue event +/// +/// Contains timing, styling, and content analysis for a single dialogue entry. +/// Uses zero-copy references to original event data where possible. +#[derive(Debug, Clone)] +pub struct DialogueInfo<'a> { + /// Reference to original event + event: &'a Event<'a>, + /// Start time in centiseconds + start_cs: u32, + /// End time in centiseconds + end_cs: u32, + /// Animation complexity score (0-10) + animation_score: u8, + + /// Overall rendering complexity (0-100) + complexity_score: u8, + /// Text analysis results + text_info: TextAnalysis<'a>, +} + +impl<'a> DialogueInfo<'a> { + /// Analyze a dialogue event comprehensively + /// + /// Performs timing parsing, text analysis, and complexity scoring. + /// Results are cached within the returned `DialogueInfo` instance. + /// + /// # Arguments + /// + /// * `event` - Dialogue event to analyze + /// + /// # Returns + /// + /// `DialogueInfo` with complete analysis results, or error if parsing fails. + /// + /// # Example + /// + /// ```rust + /// # use ass_core::analysis::events::dialogue_info::DialogueInfo; + /// # use ass_core::parser::Event; + /// let event = Event { + /// start: "0:00:00.00", + /// end: "0:00:05.00", + /// text: "Hello {\\b1}World{\\b0}!", + /// ..Default::default() + /// }; + /// + /// let info = DialogueInfo::analyze(&event)?; + /// assert_eq!(info.duration_ms(), 5000); + /// # Ok::<(), Box>(()) + /// ``` + /// + /// # Errors + /// + /// Returns an error if the event times are invalid or cannot be parsed. + pub fn analyze(event: &'a Event<'a>) -> Result { + #[cfg(feature = "plugins")] + return Self::analyze_with_registry(event, None); + #[cfg(not(feature = "plugins"))] + return Self::analyze_impl(event); + } + + /// Analyze a dialogue event with extension registry support + /// + /// Same as [`analyze`](Self::analyze) but allows custom tag handlers via registry. + /// Unhandled tags fall back to standard processing. + /// + /// # Arguments + /// + /// * `event` - Dialogue event to analyze + /// * `registry` - Optional registry for custom tag handlers + /// + /// # Returns + /// + /// `DialogueInfo` with complete analysis results, or error if parsing fails. + /// + /// # Errors + /// + /// Returns an error if the event times are invalid or cannot be parsed. + #[cfg(feature = "plugins")] + pub fn analyze_with_registry( + event: &'a Event<'a>, + registry: Option<&ExtensionRegistry>, + ) -> Result { + Self::analyze_impl_with_registry(event, registry) + } + + /// Internal implementation without plugins support + #[cfg(not(feature = "plugins"))] + fn analyze_impl(event: &'a Event<'a>) -> Result { + Self::analyze_impl_with_registry(event) + } + + /// Internal implementation that supports optional registry + fn analyze_impl_with_registry( + event: &'a Event<'a>, + #[cfg(feature = "plugins")] registry: Option<&ExtensionRegistry>, + ) -> Result { + let start_cs = parse_ass_time(event.start)?; + + let end_cs = parse_ass_time(event.end)?; + + if start_cs >= end_cs { + return Err(CoreError::parse("Start time must be before end time")); + } + + #[cfg(feature = "plugins")] + let text_info = if let Some(registry) = registry { + TextAnalysis::analyze_with_registry(event.text, Some(registry))? + } else { + TextAnalysis::analyze(event.text)? + }; + + #[cfg(not(feature = "plugins"))] + let text_info = TextAnalysis::analyze(event.text)?; + + let animation_score = calculate_animation_score(text_info.override_tags()); + let complexity_score = calculate_complexity_score( + animation_score, + text_info.char_count(), + text_info.override_tags().len(), + ); + + Ok(Self { + event, + start_cs, + end_cs, + animation_score, + complexity_score, + text_info, + }) + } + + /// Get event duration in milliseconds + #[must_use] + pub const fn duration_ms(&self) -> u32 { + (self.end_cs - self.start_cs) * 10 + } + + /// Get event duration in centiseconds + #[must_use] + pub const fn duration_cs(&self) -> u32 { + self.end_cs - self.start_cs + } + + /// Get start time in centiseconds + #[must_use] + pub const fn start_time_cs(&self) -> u32 { + self.start_cs + } + + /// Get end time in centiseconds + #[must_use] + pub const fn end_time_cs(&self) -> u32 { + self.end_cs + } + + /// Get animation complexity score (0-10) + #[must_use] + pub const fn animation_score(&self) -> u8 { + self.animation_score + } + + /// Get overall complexity score (0-100) + #[must_use] + pub const fn complexity_score(&self) -> u8 { + self.complexity_score + } + + /// Get text analysis results + #[must_use] + pub const fn text_analysis(&self) -> &TextAnalysis<'a> { + &self.text_info + } + + /// Get performance impact category + #[must_use] + pub const fn performance_impact(&self) -> PerformanceImpact { + get_performance_impact(self.complexity_score) + } + + /// Check timing relationship with another event + #[must_use] + pub const fn timing_relation(&self, other: &DialogueInfo<'_>) -> TimingRelation { + if self.start_cs == other.start_cs && self.end_cs == other.end_cs { + TimingRelation::Identical + } else if self.end_cs <= other.start_cs || other.end_cs <= self.start_cs { + TimingRelation::NoOverlap + } else if (self.start_cs <= other.start_cs && self.end_cs >= other.end_cs) + || (other.start_cs <= self.start_cs && other.end_cs >= self.end_cs) + { + TimingRelation::FullOverlap + } else { + TimingRelation::PartialOverlap + } + } + + /// Check if event overlaps with time range + #[must_use] + pub const fn overlaps_time_range(&self, start_cs: u32, end_cs: u32) -> bool { + !(self.end_cs <= start_cs || end_cs <= self.start_cs) + } + + /// Get override tag count from text analysis + #[must_use] + pub fn override_count(&self) -> usize { + self.text_info.override_tags().len() + } + + /// Get reference to original event + #[must_use] + pub const fn event(&self) -> &'a Event<'a> { + self.event + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::ast::{Event, EventType, Span}; + #[cfg(not(feature = "std"))] + #[test] + fn dialogue_info_analyze_valid() { + let event = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "Hello world!", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info = DialogueInfo::analyze(&event).unwrap(); + assert_eq!(info.duration_ms(), 5000); + assert_eq!(info.duration_cs(), 500); + assert_eq!(info.start_time_cs(), 0); + assert_eq!(info.end_time_cs(), 500); + } + + #[test] + fn dialogue_info_analyze_with_override_tags() { + let event = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "Hello {\\b1}bold{\\b0} world", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info = DialogueInfo::analyze(&event).unwrap(); + assert_eq!(info.duration_ms(), 5000); + assert_eq!(info.duration_cs(), 500); + assert_eq!(info.start_time_cs(), 0); + assert_eq!(info.end_time_cs(), 500); + assert!(!info.text_analysis().override_tags().is_empty()); + } + + #[test] + fn dialogue_info_analyze_invalid_timing_start_after_end() { + let event = Event { + event_type: EventType::Dialogue, + start: "0:00:05.00", + end: "0:00:02.00", + text: "Invalid timing", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + assert!(DialogueInfo::analyze(&event).is_err()); + } + + #[test] + fn dialogue_info_analyze_invalid_timing_equal() { + let event = Event { + event_type: EventType::Dialogue, + start: "0:00:03.00", + end: "0:00:03.00", + text: "Zero duration", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + assert!(DialogueInfo::analyze(&event).is_err()); + } + + #[test] + fn dialogue_info_timing_relation_no_overlap() { + let event1 = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "First event", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let event2 = Event { + event_type: EventType::Dialogue, + start: "0:00:06.00", + end: "0:00:10.00", + text: "Second event", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info1 = DialogueInfo::analyze(&event1).unwrap(); + let info2 = DialogueInfo::analyze(&event2).unwrap(); + + assert_eq!(info1.timing_relation(&info2), TimingRelation::NoOverlap); + assert_eq!(info2.timing_relation(&info1), TimingRelation::NoOverlap); + } + + #[test] + fn dialogue_info_timing_relation_partial_overlap() { + let event1 = Event { + event_type: EventType::Dialogue, + start: "0:00:02.50", + end: "0:00:07.50", + text: "Event on layer 1", + layer: "1", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let event2 = Event { + event_type: EventType::Dialogue, + start: "0:00:02.00", + end: "0:00:04.00", + text: "Second", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info1 = DialogueInfo::analyze(&event1).unwrap(); + let info2 = DialogueInfo::analyze(&event2).unwrap(); + + assert_eq!( + info1.timing_relation(&info2), + TimingRelation::PartialOverlap + ); + assert_eq!( + info2.timing_relation(&info1), + TimingRelation::PartialOverlap + ); + } + + #[test] + fn dialogue_info_timing_relation_full_overlap() { + let event1 = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "Complex {\\b1}bold{\\b0} text", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let event2 = Event { + event_type: EventType::Dialogue, + start: "0:00:01.00", + end: "0:00:03.00", + text: "Inner", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info1 = DialogueInfo::analyze(&event1).unwrap(); + let info2 = DialogueInfo::analyze(&event2).unwrap(); + + assert_eq!(info1.timing_relation(&info2), TimingRelation::FullOverlap); + assert_eq!(info2.timing_relation(&info1), TimingRelation::FullOverlap); + } + + #[test] + fn dialogue_info_timing_relation_identical() { + let event1 = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "Container", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let event2 = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "Second", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info1 = DialogueInfo::analyze(&event1).unwrap(); + let info2 = DialogueInfo::analyze(&event2).unwrap(); + + assert_eq!(info1.timing_relation(&info2), TimingRelation::Identical); + assert_eq!(info2.timing_relation(&info1), TimingRelation::Identical); + } + + #[test] + fn dialogue_info_overlaps_time_range() { + let event = Event { + event_type: EventType::Dialogue, + start: "0:00:02.00", + end: "0:00:05.00", + text: "Test", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info = DialogueInfo::analyze(&event).unwrap(); + + // Event is 200-500 cs + assert!(info.overlaps_time_range(100, 300)); // Overlaps start + assert!(info.overlaps_time_range(400, 600)); // Overlaps end + assert!(info.overlaps_time_range(300, 400)); // Contained within + assert!(info.overlaps_time_range(100, 600)); // Contains event + assert!(!info.overlaps_time_range(0, 200)); // Before event + assert!(!info.overlaps_time_range(500, 600)); // After event + } + + #[test] + fn dialogue_info_animation_and_complexity_scoring() { + let simple_event = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "Simple text", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let complex_event = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "Complex {\\b1}bold{\\b0} {\\i1}italic{\\i0} {\\u1}underline{\\u0} text", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let simple_info = DialogueInfo::analyze(&simple_event).unwrap(); + let complex_info = DialogueInfo::analyze(&complex_event).unwrap(); + + assert!(simple_info.animation_score() < complex_info.animation_score()); + assert!(simple_info.complexity_score() < complex_info.complexity_score()); + } + + #[test] + fn dialogue_info_override_count() { + let event = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "{\\b1}Bold {\\k50}ka{\\k100}ra{\\k75}o{\\i1}ke {\\b0}text{\\i0}", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info = DialogueInfo::analyze(&event).unwrap(); + assert_eq!(info.override_count(), 7); + } + + #[test] + fn dialogue_info_performance_impact() { + let low_impact_event = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:02.00", + text: "Hi", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info = DialogueInfo::analyze(&low_impact_event).unwrap(); + // Performance impact depends on complexity score + let _impact = info.performance_impact(); + } + + #[test] + fn dialogue_info_event_reference() { + let event = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "0:00:05.00", + text: "Fast {\\b1}dynamic{\\b0} {\\i1}text{\\i0}!", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + let info = DialogueInfo::analyze(&event).unwrap(); + assert_eq!( + info.event().text, + "Fast {\\b1}dynamic{\\b0} {\\i1}text{\\i0}!" + ); + assert_eq!(info.event().start, "0:00:00.00"); + assert_eq!(info.event().end, "0:00:05.00"); + } + + #[test] + fn dialogue_info_analyze_invalid_start_time() { + let event = Event { + event_type: EventType::Dialogue, + start: "invalid_time", + end: "0:00:05.00", + text: "Test", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + assert!(DialogueInfo::analyze(&event).is_err()); + } + + #[test] + fn dialogue_info_analyze_invalid_end_time() { + let event = Event { + event_type: EventType::Dialogue, + start: "0:00:00.00", + end: "invalid_time", + text: "Test", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + }; + + assert!(DialogueInfo::analyze(&event).is_err()); + } +} diff --git a/crates/ass-core/analysis/events/line_breaks.rs b/crates/ass-core/analysis/events/line_breaks.rs new file mode 100644 index 0000000..a562b77 --- /dev/null +++ b/crates/ass-core/analysis/events/line_breaks.rs @@ -0,0 +1,173 @@ +//! Line break handling with type preservation +//! +//! This module provides enhanced line break processing that preserves +//! the distinction between hard (\N) and soft (\n) line breaks. + +use alloc::{string::String, vec::Vec}; + +/// Type of line break in ASS text +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LineBreakType { + /// Hard line break (\N) - forces a new line + Hard, + /// Soft line break (\n) - allows wrapping + Soft, +} + +/// Line break information in processed text +#[derive(Debug, Clone)] +pub struct LineBreakInfo { + /// Position in the plain text where the break occurs + pub position: usize, + /// Type of line break + pub break_type: LineBreakType, +} + +/// Enhanced text with line break preservation +#[derive(Debug, Clone)] +pub struct TextWithLineBreaks { + /// Plain text with line breaks converted to newlines + pub text: String, + /// Information about each line break's type and position + pub line_breaks: Vec, + /// Non-breaking space positions + pub nbsp_positions: Vec, +} + +impl TextWithLineBreaks { + /// Process text preserving line break types + #[must_use] + pub fn from_text(text: &str, drawing_mode: bool) -> Self { + let mut plain_text = String::new(); + let mut line_breaks = Vec::new(); + let mut nbsp_positions = Vec::new(); + + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\\' { + if let Some(&next_ch) = chars.peek() { + match next_ch { + 'N' => { + chars.next(); // consume 'N' + if !drawing_mode { + line_breaks.push(LineBreakInfo { + position: plain_text.len(), + break_type: LineBreakType::Hard, + }); + plain_text.push('\n'); + } + } + 'n' => { + chars.next(); // consume 'n' + if !drawing_mode { + line_breaks.push(LineBreakInfo { + position: plain_text.len(), + break_type: LineBreakType::Soft, + }); + plain_text.push('\n'); + } + } + 'h' => { + chars.next(); // consume 'h' + if !drawing_mode { + nbsp_positions.push(plain_text.len()); + plain_text.push('\u{00A0}'); // Non-breaking space + } + } + _ => { + // Not a special sequence, keep both characters + plain_text.push(ch); + plain_text.push(next_ch); + chars.next(); + } + } + } else { + // Backslash at end of string + plain_text.push(ch); + } + } else { + plain_text.push(ch); + } + } + + Self { + text: plain_text, + line_breaks, + nbsp_positions, + } + } + + /// Get the type of line break at a given position + #[must_use] + pub fn get_break_type_at(&self, position: usize) -> Option { + self.line_breaks + .iter() + .find(|lb| lb.position == position) + .map(|lb| lb.break_type) + } + + /// Check if a position has a non-breaking space + #[must_use] + pub fn is_nbsp_at(&self, position: usize) -> bool { + self.nbsp_positions.contains(&position) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hard_line_break() { + let text = r"Line 1\NLine 2"; + let processed = TextWithLineBreaks::from_text(text, false); + + assert_eq!(processed.text, "Line 1\nLine 2"); + assert_eq!(processed.line_breaks.len(), 1); + assert_eq!(processed.line_breaks[0].break_type, LineBreakType::Hard); + assert_eq!(processed.line_breaks[0].position, 6); + } + + #[test] + fn test_soft_line_break() { + let text = r"Line 1\nLine 2"; + let processed = TextWithLineBreaks::from_text(text, false); + + assert_eq!(processed.text, "Line 1\nLine 2"); + assert_eq!(processed.line_breaks.len(), 1); + assert_eq!(processed.line_breaks[0].break_type, LineBreakType::Soft); + assert_eq!(processed.line_breaks[0].position, 6); + } + + #[test] + fn test_mixed_line_breaks() { + let text = r"Line 1\NLine 2\nLine 3"; + let processed = TextWithLineBreaks::from_text(text, false); + + assert_eq!(processed.text, "Line 1\nLine 2\nLine 3"); + assert_eq!(processed.line_breaks.len(), 2); + assert_eq!(processed.line_breaks[0].break_type, LineBreakType::Hard); + assert_eq!(processed.line_breaks[1].break_type, LineBreakType::Soft); + } + + #[test] + fn test_non_breaking_space() { + let text = r"Word1\hWord2"; + let processed = TextWithLineBreaks::from_text(text, false); + + assert_eq!(processed.text, "Word1\u{00A0}Word2"); + assert_eq!(processed.nbsp_positions.len(), 1); + assert_eq!(processed.nbsp_positions[0], 5); + } + + #[test] + fn test_drawing_mode_ignores_special() { + let text = r"Draw\NCommands\nHere\hIgnored"; + let processed = TextWithLineBreaks::from_text(text, true); + + assert_eq!(processed.text, "DrawCommandsHereIgnored"); + assert_eq!(processed.line_breaks.len(), 0); + assert_eq!(processed.nbsp_positions.len(), 0); + } +} diff --git a/crates/ass-core/analysis/events/mod.rs b/crates/ass-core/analysis/events/mod.rs new file mode 100644 index 0000000..dc7a63b --- /dev/null +++ b/crates/ass-core/analysis/events/mod.rs @@ -0,0 +1,71 @@ +//! ASS dialogue event analysis and processing +//! +//! Provides comprehensive analysis capabilities for ASS dialogue events including +//! timing validation, overlap detection, text analysis, and performance impact +//! assessment. Designed for zero-copy efficiency with lifetime-generic references. +//! +//! # Features +//! +//! - **Timing Analysis**: Overlap detection using O(n log n) sweep-line algorithm +//! - **Text Processing**: Override tag parsing and Unicode complexity detection +//! - **Performance Scoring**: Animation complexity and rendering impact assessment +//! - **Zero-Copy Design**: Minimal allocations via lifetime-generic spans +//! - **Standards Compliance**: Full ASS v4+ and libass 0.17.4+ compatibility +//! +//! # Performance Targets +//! +//! - Event analysis: <1ms per dialogue event +//! - Overlap detection: <1ms for 1000 events +//! - Text parsing: <0.5ms per event text +//! - Memory usage: ~1.1x input size via zero-copy spans +//! +//! # Quick Start +//! +//! ```rust +//! use ass_core::analysis::events::{DialogueInfo, find_overlapping_events}; +//! use ass_core::parser::Event; +//! +//! let event = Event { +//! start: "0:00:00.00", +//! end: "0:00:05.00", +//! text: "Hello {\\b1}world{\\b0}!", +//! ..Default::default() +//! }; +//! +//! let info = DialogueInfo::analyze(&event)?; +//! println!("Duration: {}ms", info.duration_ms()); +//! println!("Animation score: {}/10", info.animation_score()); +//! println!("Performance impact: {:?}", info.performance_impact()); +//! # Ok::<(), Box>(()) +//! ``` +//! +//! # Module Organization +//! +//! - [`dialogue_info`] - Individual event analysis and timing validation +//! - [`text_analysis`] - Text content parsing and Unicode complexity detection +//! - [`overlap`] - Efficient timing overlap detection algorithms +//! - [`utils`] - Collection operations like sorting and duration calculations + +pub mod dialogue_info; +pub mod line_breaks; +pub mod overlap; +pub mod scoring; +pub mod tags; +pub mod text_analysis; +pub mod utils; + +pub use dialogue_info::{DialogueInfo, TimingRelation}; +pub use line_breaks::{LineBreakInfo, LineBreakType, TextWithLineBreaks}; +pub use overlap::{count_overlapping_events, find_overlapping_event_refs, find_overlapping_events}; +pub use scoring::{ + calculate_animation_score, calculate_complexity_score, get_performance_impact, + PerformanceImpact, +}; +pub use tags::{ + calculate_tag_complexity, parse_override_block, DiagnosticKind, OverrideTag, TagDiagnostic, +}; +pub use text_analysis::TextAnalysis; +pub use utils::{ + calculate_average_duration, calculate_total_duration, count_overlapping_dialogue_events, + find_events_in_range, find_overlapping_dialogue_events, sort_events_by_time, +}; diff --git a/crates/ass-core/analysis/events/overlap.rs b/crates/ass-core/analysis/events/overlap.rs new file mode 100644 index 0000000..00ecd1c --- /dev/null +++ b/crates/ass-core/analysis/events/overlap.rs @@ -0,0 +1,550 @@ +//! Event timing overlap detection using sweep-line algorithm +//! +//! Provides efficient O(n log n) overlap detection for ASS dialogue events +//! using a sweep-line algorithm instead of naive O(n²) approaches. +//! +//! # Algorithm +//! +//! Uses sweep-line technique with start/end events sorted by time. +//! Maintains active event set to detect overlaps in optimal time complexity. +//! +//! # Performance +//! +//! - Time complexity: O(n log n) due to sorting +//! - Space complexity: O(n) for sweep events and active set +//! - Target: <1ms for 1000 events on typical hardware +//! +//! # Example +//! +//! ```rust +//! use ass_core::analysis::events::overlap::find_overlapping_events; +//! use ass_core::parser::Event; +//! +//! let events = vec![ +//! Event { start: "0:00:00.00", end: "0:00:05.00", ..Default::default() }, +//! Event { start: "0:00:03.00", end: "0:00:08.00", ..Default::default() }, +//! ]; +//! +//! let overlaps = find_overlapping_events(&events)?; +//! assert_eq!(overlaps.len(), 1); +//! assert_eq!(overlaps[0], (0, 1)); +//! # Ok::<(), Box>(()) +//! ``` + +use crate::{ + parser::Event, + utils::{parse_ass_time, CoreError}, + Result, +}; +use alloc::vec::Vec; +use core::cmp::Ordering; + +/// Event type discriminant for sweep-line algorithm +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SweepEventType { + /// Event represents dialogue start time + Start, + /// Event represents dialogue end time + End, +} + +/// Sweep-line event for overlap detection algorithm +#[derive(Debug, Clone)] +struct SweepEvent { + /// Time of this sweep event in centiseconds + time: u32, + /// Type of event (start or end) + event_type: SweepEventType, + /// Index of the original event in the input vector + event_index: usize, +} + +impl PartialEq for SweepEvent { + fn eq(&self, other: &Self) -> bool { + self.time == other.time && self.event_type == other.event_type + } +} + +impl Eq for SweepEvent {} + +impl PartialOrd for SweepEvent { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for SweepEvent { + fn cmp(&self, other: &Self) -> Ordering { + match self.time.cmp(&other.time) { + Ordering::Equal => match (self.event_type, other.event_type) { + (SweepEventType::End, SweepEventType::Start) => Ordering::Less, + (SweepEventType::Start, SweepEventType::End) => Ordering::Greater, + _ => Ordering::Equal, + }, + other => other, + } + } +} + +/// Generic overlap detection using sweep-line algorithm +/// +/// Internal helper function that implements the core sweep-line algorithm +/// for detecting overlapping time ranges. Works with any iterator that +/// provides start and end times. +fn find_overlaps_generic(events: I, get_times: F) -> Result> +where + I: ExactSizeIterator, + F: Fn(usize, I::Item) -> Result<(u32, u32)>, +{ + if events.len() < 2 { + return Ok(Vec::new()); + } + + let mut sweep_events = Vec::with_capacity(events.len() * 2); + + for (index, event) in events.enumerate() { + let (start_time, end_time) = get_times(index, event)?; + + sweep_events.push(SweepEvent { + time: start_time, + event_type: SweepEventType::Start, + event_index: index, + }); + + sweep_events.push(SweepEvent { + time: end_time, + event_type: SweepEventType::End, + event_index: index, + }); + } + + sweep_events.sort(); + + let mut active_events = Vec::new(); + let mut overlaps = Vec::new(); + + for sweep_event in sweep_events { + match sweep_event.event_type { + SweepEventType::Start => { + for &active_index in &active_events { + overlaps.push((active_index, sweep_event.event_index)); + } + active_events.push(sweep_event.event_index); + } + SweepEventType::End => { + if let Some(pos) = active_events + .iter() + .position(|&x| x == sweep_event.event_index) + { + active_events.remove(pos); + } + } + } + } + + Ok(overlaps) +} + +/// Find overlapping events using sweep-line algorithm +/// +/// Efficiently detects all pairs of events with overlapping time ranges. +/// Returns vector of (`event1_index`, `event2_index`) pairs where events overlap. +/// +/// # Arguments +/// +/// * `events` - Slice of events to analyze for overlaps +/// +/// # Returns +/// +/// Vector of index pairs representing overlapping events, or error if +/// time parsing fails. +/// +/// # Example +/// +/// ```rust +/// # use ass_core::analysis::events::overlap::find_overlapping_events; +/// # use ass_core::parser::Event; +/// let events = vec![ +/// Event { start: "0:00:00.00", end: "0:00:05.00", ..Default::default() }, +/// Event { start: "0:00:03.00", end: "0:00:08.00", ..Default::default() }, +/// Event { start: "0:00:10.00", end: "0:00:15.00", ..Default::default() }, +/// ]; +/// +/// let overlaps = find_overlapping_events(&events)?; +/// assert_eq!(overlaps, vec![(0, 1)]); +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error if any event has invalid time format that cannot be parsed. +pub fn find_overlapping_events(events: &[Event]) -> Result> { + find_overlaps_generic(events.iter(), |_index, event| { + let start_time = parse_ass_time(event.start) + .map_err(|_| CoreError::parse("Invalid start time format"))?; + let end_time = + parse_ass_time(event.end).map_err(|_| CoreError::parse("Invalid end time format"))?; + Ok((start_time, end_time)) + }) +} + +/// Find overlapping events from event references +/// +/// Memory-efficient version that works with Event references instead of +/// owned Event structs. Useful when working with large event collections. +/// +/// # Arguments +/// +/// * `events` - Slice of event references to analyze +/// +/// # Returns +/// +/// Vector of index pairs for overlapping events, or parsing error. +/// +/// # Errors +/// +/// Returns an error if any event has invalid time format that cannot be parsed. +pub fn find_overlapping_event_refs(events: &[&Event]) -> Result> { + find_overlaps_generic(events.iter(), |_index, event| { + let start_time = parse_ass_time(event.start) + .map_err(|_| CoreError::parse("Invalid start time format"))?; + let end_time = + parse_ass_time(event.end).map_err(|_| CoreError::parse("Invalid end time format"))?; + Ok((start_time, end_time)) + }) +} + +/// Count overlapping event pairs efficiently +/// +/// Returns only the count without generating the full list of overlap pairs. +/// More memory efficient when only the count is needed. +/// +/// # Arguments +/// +/// * `events` - Events to check for overlaps +/// +/// # Returns +/// +/// Number of overlapping event pairs found. +/// +/// # Errors +/// +/// Returns an error if any event has invalid time format that cannot be parsed. +pub fn count_overlapping_events(events: &[Event]) -> Result { + Ok(find_overlapping_events(events)?.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::ast::Span; + #[cfg(not(feature = "std"))] + use alloc::{string::ToString, vec}; + + fn create_test_event(start: &'static str, end: &'static str) -> Event<'static> { + Event { + event_type: crate::parser::ast::EventType::Dialogue, + start, + end, + text: "Test", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + } + } + + #[test] + fn no_overlaps_empty_list() { + let events = vec![]; + let result = find_overlapping_events(&events).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn no_overlaps_single_event() { + let events = vec![create_test_event("0:00:00.00", "0:00:05.00")]; + let result = find_overlapping_events(&events).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn no_overlaps_sequential_events() { + let events = vec![ + create_test_event("0:00:00.00", "0:00:05.00"), + create_test_event("0:00:05.00", "0:00:10.00"), + create_test_event("0:00:10.00", "0:00:15.00"), + ]; + let result = find_overlapping_events(&events).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn simple_overlap() { + let events = vec![ + create_test_event("0:00:00.00", "0:00:05.00"), + create_test_event("0:00:03.00", "0:00:08.00"), + ]; + let result = find_overlapping_events(&events).unwrap(); + assert_eq!(result, vec![(0, 1)]); + } + + #[test] + fn multiple_overlaps() { + let events = vec![ + create_test_event("0:00:00.00", "0:00:10.00"), + create_test_event("0:00:02.00", "0:00:05.00"), + create_test_event("0:00:03.00", "0:00:08.00"), + ]; + let result = find_overlapping_events(&events).unwrap(); + assert_eq!(result, vec![(0, 1), (0, 2), (1, 2)]); + } + + #[test] + fn count_overlaps() { + let events = vec![ + create_test_event("0:00:00.00", "0:00:05.00"), + create_test_event("0:00:03.00", "0:00:08.00"), + ]; + let count = count_overlapping_events(&events).unwrap(); + assert_eq!(count, 1); + } + + #[test] + fn test_sweep_event_ordering() { + let start_event = SweepEvent { + time: 1000, + event_type: SweepEventType::Start, + event_index: 0, + }; + let end_event = SweepEvent { + time: 1000, + event_type: SweepEventType::End, + event_index: 1, + }; + + // End events should come before start events at the same time + assert!(end_event < start_event); + assert_eq!(start_event.partial_cmp(&end_event), Some(Ordering::Greater)); + } + + #[test] + fn test_sweep_event_equality() { + let event1 = SweepEvent { + time: 1000, + event_type: SweepEventType::Start, + event_index: 0, + }; + let event2 = SweepEvent { + time: 1000, + event_type: SweepEventType::Start, + event_index: 1, + }; + + assert_eq!(event1, event2); + } + + #[test] + fn find_overlapping_event_refs_test() { + let event1 = create_test_event("0:00:00.00", "0:00:05.00"); + let event2 = create_test_event("0:00:03.00", "0:00:08.00"); + let event3 = create_test_event("0:00:10.00", "0:00:15.00"); + + let event_refs = vec![&event1, &event2, &event3]; + let result = find_overlapping_event_refs(&event_refs).unwrap(); + assert_eq!(result, vec![(0, 1)]); + } + + #[test] + fn find_overlapping_event_refs_empty() { + let event_refs: Vec<&Event> = vec![]; + let result = find_overlapping_event_refs(&event_refs).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn find_overlapping_event_refs_single() { + let event1 = create_test_event("0:00:00.00", "0:00:05.00"); + let event_refs = vec![&event1]; + let result = find_overlapping_event_refs(&event_refs).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_overlaps_with_identical_times() { + let events = vec![ + create_test_event("0:00:00.00", "0:00:05.00"), + create_test_event("0:00:00.00", "0:00:05.00"), + ]; + let result = find_overlapping_events(&events).unwrap(); + assert_eq!(result, vec![(0, 1)]); + } + + #[test] + fn test_adjacent_events_no_overlap() { + let events = vec![ + create_test_event("0:00:00.00", "0:00:05.00"), + create_test_event("0:00:05.00", "0:00:10.00"), + ]; + let result = find_overlapping_events(&events).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_complex_overlap_scenario() { + let events = vec![ + create_test_event("0:00:00.00", "0:00:10.00"), // Event 0 + create_test_event("0:00:02.00", "0:00:05.00"), // Event 1 - overlaps with 0 + create_test_event("0:00:03.00", "0:00:08.00"), // Event 2 - overlaps with 0, 1 + create_test_event("0:00:06.00", "0:00:09.00"), // Event 3 - overlaps with 0, 2 + create_test_event("0:00:11.00", "0:00:15.00"), // Event 4 - no overlaps + ]; + let result = find_overlapping_events(&events).unwrap(); + + // Expected overlaps: (0,1), (0,2), (0,3), (1,2), (2,3) + assert_eq!(result.len(), 5); + assert!(result.contains(&(0, 1))); + assert!(result.contains(&(0, 2))); + assert!(result.contains(&(0, 3))); + assert!(result.contains(&(1, 2))); + assert!(result.contains(&(2, 3))); + } + + #[test] + fn test_sweep_event_ordering_different_times() { + let early_event = SweepEvent { + time: 500, + event_type: SweepEventType::Start, + event_index: 0, + }; + let late_event = SweepEvent { + time: 1000, + event_type: SweepEventType::End, + event_index: 1, + }; + + assert!(early_event < late_event); + } + + #[test] + fn test_invalid_start_time_format_error() { + let events = vec![ + create_test_event("invalid_time", "0:00:05.00"), + create_test_event("0:00:03.00", "0:00:08.00"), + ]; + let result = find_overlapping_events(&events); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid start time format")); + } + + #[test] + fn test_invalid_end_time_format_error() { + let events = vec![ + create_test_event("0:00:00.00", "invalid_time"), + create_test_event("0:00:03.00", "0:00:08.00"), + ]; + let result = find_overlapping_events(&events); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid end time format")); + } + + #[test] + fn test_invalid_time_formats_in_multiple_events() { + let events = vec![ + create_test_event("0:00:00.00", "0:00:05.00"), // Valid + create_test_event("bad_start", "0:00:08.00"), // Invalid start + create_test_event("0:00:03.00", "bad_end"), // Invalid end + ]; + let result = find_overlapping_events(&events); + assert!(result.is_err()); + // Should fail on first invalid time encountered + } + + #[test] + fn test_count_overlapping_events_with_invalid_times() { + let events = vec![ + create_test_event("invalid_time", "0:00:05.00"), + create_test_event("0:00:03.00", "0:00:08.00"), + ]; + let result = count_overlapping_events(&events); + assert!(result.is_err()); + } + + #[test] + fn test_find_overlapping_event_refs_with_invalid_times() { + let event1 = create_test_event("invalid_start", "0:00:05.00"); + let event2 = create_test_event("0:00:03.00", "0:00:08.00"); + let event_refs = vec![&event1, &event2]; + let result = find_overlapping_event_refs(&event_refs); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid start time format")); + } + + #[test] + fn test_find_overlaps_generic_error_propagation() { + let events = ["valid", "invalid"]; + let result = find_overlaps_generic(events.iter(), |_index, event| { + if *event == "invalid" { + Err(CoreError::parse("Test error")) + } else { + Ok((1000u32, 2000u32)) + } + }); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Test error")); + } + + #[test] + fn test_malformed_time_formats_various() { + let test_cases = vec![ + ("invalid", "0:00:05.00"), // Non-time format + ("0:60:00.00", "0:00:05.00"), // Invalid minute (60) + ("0:00:60.00", "0:00:05.00"), // Invalid second (60) + ("0:00:00.100", "0:00:05.00"), // Invalid centiseconds (100) + ("0:00:00.00", "invalid"), // Invalid end time + ("0:00:00.00", "0:60:00.00"), // Invalid end minute + ("abc:def:gh.ij", "0:00:05.00"), // Non-numeric components + ]; + + for (start, end) in test_cases { + // Need at least 2 events to trigger time parsing in overlap detection + let events = vec![ + create_test_event(start, end), // Event with invalid time + create_test_event("0:00:10.00", "0:00:15.00"), // Valid event for comparison + ]; + let result = find_overlapping_events(&events); + assert!( + result.is_err(), + "Expected error for time format: {start} -> {end}, got: {result:?}" + ); + } + } + + #[test] + fn test_find_overlapping_event_refs_with_invalid_end_time() { + let event1 = create_test_event("0:00:00.00", "invalid_end"); + let event2 = create_test_event("0:00:03.00", "0:00:08.00"); + let event_refs = vec![&event1, &event2]; + let result = find_overlapping_event_refs(&event_refs); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid end time format")); + } +} diff --git a/crates/ass-core/analysis/events/scoring.rs b/crates/ass-core/analysis/events/scoring.rs new file mode 100644 index 0000000..8ce30f8 --- /dev/null +++ b/crates/ass-core/analysis/events/scoring.rs @@ -0,0 +1,219 @@ +//! Complexity scoring algorithms for ASS events +//! +//! Provides efficient calculation of animation complexity and rendering performance +//! impact scores for dialogue events. Scores are computed based on override tag +//! analysis, text content, and performance heuristics. +//! +//! # Scoring System +//! +//! - Animation Score: 0-10 scale based on tag complexity +//! - Complexity Score: 0-100 scale combining multiple factors +//! - Performance Impact: Categorical assessment for rendering optimization +//! +//! # Performance +//! +//! - Target: <0.1ms per scoring operation +//! - Memory: Zero allocations, operates on borrowed data +//! - Scalability: Linear complexity O(n) where n = tag count + +use crate::analysis::events::tags::OverrideTag; + +/// Performance impact category for rendering complexity +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum PerformanceImpact { + /// Minimal impact - simple static text + Minimal, + /// Low impact - basic formatting + Low, + /// Medium impact - animations or complex styling + Medium, + /// High impact - many animations or large text + High, + /// Critical impact - may cause performance issues + Critical, +} + +/// Calculate animation complexity score from override tags +/// +/// Analyzes ASS override tags to determine animation complexity on a 0-10 scale. +/// Higher scores indicate more computationally expensive rendering operations. +/// +/// # Scoring Rules +/// +/// - Basic formatting (b, i, u, s, colors): 1 point each +/// - Positioning (pos, an, org): 2 points each +/// - Transforms (frx, fry, frz, fscx, fscy, etc.): 3 points each +/// - Movement (move): 4 points +/// - Transitions (t): 5 points +/// - Drawing (p): 8 points +/// +/// # Arguments +/// +/// * `tags` - Slice of parsed override tags +/// +/// # Returns +/// +/// Animation complexity score capped at 10 +/// +/// # Example +/// +/// ```rust +/// # use ass_core::analysis::events::scoring::calculate_animation_score; +/// # use ass_core::analysis::events::tags::OverrideTag; +/// let tags = vec![]; // Empty for this example +/// let score = calculate_animation_score(&tags); +/// assert_eq!(score, 0); +/// ``` +#[must_use] +pub fn calculate_animation_score(tags: &[OverrideTag<'_>]) -> u8 { + tags.iter() + .map(|tag| match tag.name() { + "b" | "i" | "u" | "s" | "c" | "1c" | "2c" | "3c" | "4c" | "alpha" | "1a" | "2a" + | "3a" | "4a" => 1, + "frx" | "fry" | "frz" | "fscx" | "fscy" | "fsp" | "fad" | "fade" | "clip" | "iclip" => { + 3 + } + "move" => 4, + "t" | "pbo" => 5, + "p" => 8, + _ => 2, + }) + .sum::() + .min(10) +} + +/// Calculate overall complexity score combining multiple factors +/// +/// Computes a comprehensive complexity score (0-100) by combining animation +/// complexity, text length, and override tag count. Used for performance +/// optimization and rendering strategy selection. +/// +/// # Scoring Components +/// +/// - Animation score: Weighted 5x (0-50 points) +/// - Character count: Variable based on text length (0-50 points) +/// - Override count: Variable based on tag density (0-35 points) +/// +/// # Arguments +/// +/// * `animation_score` - Pre-calculated animation complexity (0-10) +/// * `char_count` - Number of characters in event text +/// * `override_count` - Number of override tags found +/// +/// # Returns +/// +/// Overall complexity score capped at 100 +/// +/// # Example +/// +/// ```rust +/// # use ass_core::analysis::events::scoring::calculate_complexity_score; +/// let score = calculate_complexity_score(3, 100, 5); +/// assert!(score > 0 && score <= 100); +/// ``` +#[must_use] +pub fn calculate_complexity_score( + animation_score: u8, + char_count: usize, + override_count: usize, +) -> u8 { + let mut score = u32::from(animation_score) * 5; + + score += match char_count { + 0..=50 => 0, + 51..=200 => 5, + 201..=500 => 15, + 501..=1000 => 30, + _ => 50, + }; + + score += match override_count { + 0 => 0, + 1..=5 => 5, + 6..=15 => 15, + 16..=30 => 25, + _ => 35, + }; + + (score.min(255) as u8).min(100) +} + +/// Determine performance impact category from complexity score +/// +/// Maps numerical complexity scores to categorical performance impact levels +/// for easier rendering optimization decisions. +/// +/// # Arguments +/// +/// * `complexity_score` - Overall complexity score (0-100) +/// +/// # Returns +/// +/// Performance impact category for rendering optimization +/// +/// # Example +/// +/// ```rust +/// # use ass_core::analysis::events::scoring::{get_performance_impact, PerformanceImpact}; +/// let impact = get_performance_impact(75); +/// assert_eq!(impact, PerformanceImpact::High); +/// ``` +#[must_use] +pub const fn get_performance_impact(complexity_score: u8) -> PerformanceImpact { + match complexity_score { + 0..=20 => PerformanceImpact::Minimal, + 21..=40 => PerformanceImpact::Low, + 41..=60 => PerformanceImpact::Medium, + 61..=80 => PerformanceImpact::High, + _ => PerformanceImpact::Critical, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(not(feature = "std"))] + use alloc::vec; + + #[test] + fn test_animation_score_empty() { + let tags = vec![]; + assert_eq!(calculate_animation_score(&tags), 0); + } + + #[test] + fn test_animation_score_basic_formatting() { + // Mock tags would go here in a real implementation + // Testing with empty for now since OverrideTag construction requires parser + let tags = vec![]; + assert_eq!(calculate_animation_score(&tags), 0); + } + + #[test] + fn test_complexity_score_minimal() { + let score = calculate_complexity_score(0, 10, 0); + assert_eq!(score, 0); + } + + #[test] + fn test_complexity_score_high() { + let score = calculate_complexity_score(10, 1000, 50); + assert_eq!(score, 100); + } + + #[test] + fn test_performance_impact_mapping() { + assert_eq!(get_performance_impact(0), PerformanceImpact::Minimal); + assert_eq!(get_performance_impact(30), PerformanceImpact::Low); + assert_eq!(get_performance_impact(50), PerformanceImpact::Medium); + assert_eq!(get_performance_impact(70), PerformanceImpact::High); + assert_eq!(get_performance_impact(90), PerformanceImpact::Critical); + } + + #[test] + fn test_complexity_score_medium_char_count() { + // Test the 501-1000 character range (line 127) + let score = calculate_complexity_score(0, 750, 0); + assert_eq!(score, 30); // Should match the 501..=1000 => 30 case + } +} diff --git a/crates/ass-core/analysis/events/tags.rs b/crates/ass-core/analysis/events/tags.rs new file mode 100644 index 0000000..d586f88 --- /dev/null +++ b/crates/ass-core/analysis/events/tags.rs @@ -0,0 +1,575 @@ +//! Override tag parsing and complexity analysis +//! +//! Provides efficient parsing of ASS override tags within dialogue text blocks. +//! Handles malformed syntax gracefully with diagnostic collection and uses +//! zero-copy references for optimal performance. +//! +//! # Features +//! +//! - Zero-copy tag argument parsing via string slices +//! - Robust error recovery for malformed tag syntax +//! - Complexity scoring for rendering optimization +//! - Diagnostic collection for parser feedback +//! - Unicode-aware character handling +//! +//! # Performance +//! +//! - Target: <0.1ms per tag block +//! - Memory: Zero allocations via borrowed references +//! - Complexity: O(n) where n = character count + +use alloc::{string::String, vec::Vec}; + +#[cfg(feature = "plugins")] +use crate::plugin::{ExtensionRegistry, TagResult}; + +/// Diagnostic information for tag parsing issues +#[derive(Debug, Clone)] +pub struct TagDiagnostic<'a> { + /// Text span containing the issue + pub span: &'a str, + /// Byte offset in original text + pub offset: usize, + /// Type of diagnostic issue + pub kind: DiagnosticKind, +} + +/// Types of tag parsing diagnostics +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiagnosticKind { + /// Empty override tag like {} + EmptyOverride, + /// Malformed tag syntax + MalformedTag, + /// Unknown or invalid tag name + UnknownTag(String), +} + +/// Single ASS override tag with analysis results +/// +/// Represents a parsed override tag like `{\b1}` or `{\pos(100,200)}`. +/// Contains zero-copy references to original text for efficiency. +#[derive(Debug, Clone)] +pub struct OverrideTag<'a> { + /// Tag name (e.g., "b", "pos", "move") + name: &'a str, + /// Tag arguments as original text slice + args: &'a str, + /// Complexity score for rendering (0-5) + complexity: u8, + /// Byte position in original text + position: usize, +} + +impl<'a> OverrideTag<'a> { + /// Create a new override tag + #[must_use] + pub const fn new(name: &'a str, args: &'a str, complexity: u8, position: usize) -> Self { + Self { + name, + args, + complexity, + position, + } + } + + /// Get tag name + #[must_use] + pub const fn name(&self) -> &'a str { + self.name + } + + /// Get tag arguments + #[must_use] + pub const fn args(&self) -> &'a str { + self.args + } + + /// Get complexity score + #[must_use] + pub const fn complexity(&self) -> u8 { + self.complexity + } + + /// Get position in original text + #[must_use] + pub const fn position(&self) -> usize { + self.position + } +} + +/// Parse override tags within a tag block +/// +/// Extracts and analyzes all override tags found within an ASS tag block. +/// Handles malformed syntax gracefully by collecting diagnostics rather than failing. +/// +/// # Arguments +/// +/// * `content` - Text content within the override block (without braces) +/// * `start_pos` - Byte offset of the block start in original text +/// * `tags` - Vector to collect parsed tags +/// * `diagnostics` - Vector to collect parsing issues +/// +/// # Example +/// +/// ```rust +/// # use ass_core::analysis::events::tags::{parse_override_block, OverrideTag, TagDiagnostic}; +/// let mut tags = Vec::new(); +/// let mut diagnostics = Vec::new(); +/// +/// parse_override_block("\\b1\\i1", 0, &mut tags, &mut diagnostics); +/// assert_eq!(tags.len(), 2); +/// ``` +pub fn parse_override_block<'a>( + content: &'a str, + start_pos: usize, + tags: &mut Vec>, + diagnostics: &mut Vec>, +) { + let mut char_pos = 0; + let mut byte_pos = 0; + let chars: Vec = content.chars().collect(); + + while char_pos < chars.len() { + if chars[char_pos] == '\\' { + let tag_start = byte_pos; + byte_pos += 1; // '\' is ascii, 1 byte in utf8 + char_pos += 1; + + let name_char_start = char_pos; + let name_start = byte_pos; + // Tags can start with digits (e.g., \1c, \2c, \3c, \4c for colors, \1a, \2a for alpha) + // But after the initial characters, only continue if alphabetic to avoid consuming arguments + let mut tag_name_len = 0; + while char_pos < chars.len() { + if tag_name_len == 0 && chars[char_pos].is_ascii_digit() { + // Allow digit as first character (for \1c, \2c, etc.) + byte_pos += 1; + char_pos += 1; + tag_name_len += 1; + } else if chars[char_pos].is_ascii_alphabetic() { + byte_pos += 1; + char_pos += 1; + tag_name_len += 1; + } else { + break; + } + } + + if char_pos > name_char_start { + let name_end = byte_pos; + let args_start = byte_pos; + + // Special handling for \t tag which can contain nested tags in parentheses + let tag_name_preview = &content[name_start..name_end]; + if tag_name_preview == "t" && char_pos < chars.len() && chars[char_pos] == '(' { + // For \t tag with parentheses, we need to find the matching closing parenthesis + // and include everything inside, even if it contains backslashes + let mut paren_depth = 0; + while char_pos < chars.len() { + if chars[char_pos] == '(' { + paren_depth += 1; + } else if chars[char_pos] == ')' { + paren_depth -= 1; + if paren_depth == 0 { + byte_pos += 1; // Include the closing parenthesis + char_pos += 1; // Include the closing parenthesis + break; + } + } + byte_pos += chars[char_pos].len_utf8(); + char_pos += 1; + } + } else { + // For other tags, stop at the next backslash + while char_pos < chars.len() && chars[char_pos] != '\\' { + byte_pos += chars[char_pos].len_utf8(); + char_pos += 1; + } + } + + let tag_name = &content[name_start..name_end]; + let args = &content[args_start..byte_pos]; + + let complexity = calculate_tag_complexity(tag_name); + + if tag_name.trim().is_empty() { + diagnostics.push(TagDiagnostic { + span: &content[tag_start..byte_pos], + offset: start_pos + tag_start, + kind: DiagnosticKind::EmptyOverride, + }); + } else { + tags.push(OverrideTag { + name: tag_name, + args, + complexity, + position: start_pos + tag_start, + }); + } + } else { + let span_end = (tag_start + 2).min(content.len()); + diagnostics.push(TagDiagnostic { + span: &content[tag_start..span_end], + offset: start_pos + tag_start, + kind: DiagnosticKind::EmptyOverride, + }); + byte_pos += chars[char_pos].len_utf8(); + char_pos += 1; + } + } else { + byte_pos += chars[char_pos].len_utf8(); + char_pos += 1; + } + } +} + +/// Parse override block with extension registry support +/// +/// Enhanced version of [`parse_override_block`] that can use custom tag handlers +/// from an extension registry. Unknown tags are first checked against the registry +/// before falling back to standard processing. +/// +/// # Arguments +/// +/// * `content` - Text content to parse (e.g., "\\b1\\i1") +/// * `start_pos` - Byte offset in the original source text +/// * `tags` - Mutable vector to collect parsed tags +/// * `diagnostics` - Mutable vector to collect parsing diagnostics +/// * `registry` - Optional registry for custom tag handlers +#[cfg(feature = "plugins")] +pub fn parse_override_block_with_registry<'a>( + content: &'a str, + start_pos: usize, + tags: &mut Vec>, + diagnostics: &mut Vec>, + registry: Option<&ExtensionRegistry>, +) { + let mut char_pos = 0; + let mut byte_pos = 0; + let chars: Vec = content.chars().collect(); + + while char_pos < chars.len() { + if chars[char_pos] == '\\' { + let tag_start = byte_pos; + byte_pos += 1; + char_pos += 1; + + let name_char_start = char_pos; + let name_start = byte_pos; + // Tags can start with digits (e.g., \1c, \2c, \3c, \4c for colors, \1a, \2a for alpha) + // But after the initial characters, only continue if alphabetic to avoid consuming arguments + let mut tag_name_len = 0; + while char_pos < chars.len() { + if tag_name_len == 0 && chars[char_pos].is_ascii_digit() { + // Allow digit as first character (for \1c, \2c, etc.) + byte_pos += 1; + char_pos += 1; + tag_name_len += 1; + } else if chars[char_pos].is_ascii_alphabetic() { + byte_pos += 1; + char_pos += 1; + tag_name_len += 1; + } else { + break; + } + } + + if char_pos > name_char_start { + let name_end = byte_pos; + let args_start = byte_pos; + + // Special handling for \t tag which can contain nested tags in parentheses + let tag_name_preview = &content[name_start..name_end]; + if tag_name_preview == "t" && char_pos < chars.len() && chars[char_pos] == '(' { + // For \t tag with parentheses, we need to find the matching closing parenthesis + // and include everything inside, even if it contains backslashes + let mut paren_depth = 0; + while char_pos < chars.len() { + if chars[char_pos] == '(' { + paren_depth += 1; + } else if chars[char_pos] == ')' { + paren_depth -= 1; + if paren_depth == 0 { + byte_pos += 1; + char_pos += 1; // Include the closing parenthesis + break; + } + } + byte_pos += chars[char_pos].len_utf8(); + char_pos += 1; + } + } else { + // For other tags, stop at the next backslash + while char_pos < chars.len() && chars[char_pos] != '\\' { + byte_pos += chars[char_pos].len_utf8(); + char_pos += 1; + } + } + + let tag_name = &content[name_start..name_end]; + let args = &content[args_start..byte_pos]; + + // Try to process with registry first + let mut handled_by_plugin = false; + if let Some(registry) = registry { + if let Some(result) = registry.process_tag(tag_name, args) { + match result { + TagResult::Processed => { + // Plugin successfully handled the tag + handled_by_plugin = true; + + // Still add to tags for downstream analysis + tags.push(OverrideTag { + name: tag_name, + args, + complexity: 2, // Default complexity for plugin tags + position: start_pos + tag_start, + }); + } + TagResult::Failed(_msg) => { + // Plugin failed to process the tag + diagnostics.push(TagDiagnostic { + span: &content[tag_start..byte_pos], + offset: start_pos + tag_start, + kind: DiagnosticKind::MalformedTag, + }); + } + TagResult::Ignored => { + // Plugin ignored the tag, fall back to standard processing + } + } + } + } + + // If not handled by plugin, use standard processing + if !handled_by_plugin { + let complexity = calculate_tag_complexity(tag_name); + + if tag_name.trim().is_empty() { + diagnostics.push(TagDiagnostic { + span: &content[tag_start..byte_pos], + offset: start_pos + tag_start, + kind: DiagnosticKind::EmptyOverride, + }); + } else { + tags.push(OverrideTag { + name: tag_name, + args, + complexity, + position: start_pos + tag_start, + }); + } + } + } else { + let span_end = (tag_start + 2).min(content.len()); + diagnostics.push(TagDiagnostic { + span: &content[tag_start..span_end], + offset: start_pos + tag_start, + kind: DiagnosticKind::EmptyOverride, + }); + byte_pos += chars[char_pos].len_utf8(); + char_pos += 1; + } + } else { + byte_pos += chars[char_pos].len_utf8(); + char_pos += 1; + } + } +} + +/// Calculate rendering complexity for a tag +/// +/// Assigns complexity scores based on computational cost of rendering operations. +/// Higher scores indicate more expensive rendering operations. +/// +/// # Complexity Scale +/// +/// - 1: Basic formatting (bold, italic, colors) +/// - 2: Positioning and styling (position, alignment, borders) +/// - 3: Animations and transforms (movement, fades, rotations) +/// - 4: Advanced animations (transitions, complex effects) +/// - 5: Drawing commands (vector graphics) +/// +/// # Arguments +/// +/// * `tag_name` - Name of the ASS tag to score +/// +/// # Returns +/// +/// Complexity score from 1-5, defaults to 2 for unknown tags +/// +/// # Example +/// +/// ```rust +/// # use ass_core::analysis::events::tags::calculate_tag_complexity; +/// assert_eq!(calculate_tag_complexity("b"), 1); +/// assert_eq!(calculate_tag_complexity("pos"), 2); +/// assert_eq!(calculate_tag_complexity("move"), 3); +/// assert_eq!(calculate_tag_complexity("t"), 4); +/// assert_eq!(calculate_tag_complexity("p"), 5); +/// ``` +#[must_use] +pub fn calculate_tag_complexity(tag_name: &str) -> u8 { + match tag_name { + "b" | "i" | "u" | "s" | "c" | "1c" | "2c" | "3c" | "4c" | "alpha" | "1a" | "2a" | "3a" + | "4a" | "fn" | "fs" => 1, + + "move" | "fad" | "fade" | "frx" | "fry" | "frz" | "fscx" | "fscy" | "fsp" | "clip" + | "iclip" => 3, + "t" | "pbo" => 4, + "p" => 5, + _ => 2, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tag_complexity_basic() { + assert_eq!(calculate_tag_complexity("b"), 1); + assert_eq!(calculate_tag_complexity("i"), 1); + assert_eq!(calculate_tag_complexity("c"), 1); + } + + #[test] + fn test_tag_complexity_positioning() { + assert_eq!(calculate_tag_complexity("pos"), 2); + assert_eq!(calculate_tag_complexity("an"), 2); + assert_eq!(calculate_tag_complexity("org"), 2); + } + + #[test] + fn test_tag_complexity_animation() { + assert_eq!(calculate_tag_complexity("move"), 3); + assert_eq!(calculate_tag_complexity("fade"), 3); + assert_eq!(calculate_tag_complexity("frz"), 3); + } + + #[test] + fn test_tag_complexity_advanced() { + assert_eq!(calculate_tag_complexity("t"), 4); + assert_eq!(calculate_tag_complexity("pbo"), 4); + } + + #[test] + fn test_tag_complexity_drawing() { + assert_eq!(calculate_tag_complexity("p"), 5); + } + + #[test] + fn test_tag_complexity_unknown() { + assert_eq!(calculate_tag_complexity("unknown"), 2); + assert_eq!(calculate_tag_complexity(""), 2); + } + + #[test] + fn test_parse_override_block_simple() { + let mut tags = Vec::new(); + let mut diagnostics = Vec::new(); + + parse_override_block("\\b1", 0, &mut tags, &mut diagnostics); + + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].name(), "b"); + assert_eq!(tags[0].args(), "1"); + assert_eq!(diagnostics.len(), 0); + } + + #[test] + fn test_parse_override_block_multiple() { + let mut tags = Vec::new(); + let mut diagnostics = Vec::new(); + + parse_override_block("\\b1\\i1\\pos(100,200)", 0, &mut tags, &mut diagnostics); + + assert_eq!(tags.len(), 3); + assert_eq!(tags[0].name(), "b"); + assert_eq!(tags[1].name(), "i"); + assert_eq!(tags[2].name(), "pos"); + assert_eq!(tags[2].args(), "(100,200)"); + } + + #[test] + fn test_parse_override_block_empty() { + let mut tags = Vec::new(); + let mut diagnostics = Vec::new(); + + parse_override_block("", 0, &mut tags, &mut diagnostics); + + assert_eq!(tags.len(), 0); + assert_eq!(diagnostics.len(), 0); + } + + #[test] + fn test_override_tag_getters() { + let mut tags = Vec::new(); + let mut diagnostics = Vec::new(); + + parse_override_block( + "\\b1\\move(100,200,300,400)", + 10, + &mut tags, + &mut diagnostics, + ); + + assert_eq!(tags.len(), 2); + + // Test complexity() getter (lines 76-77) + assert_eq!(tags[0].complexity(), 1); // 'b' has complexity 1 + assert_eq!(tags[1].complexity(), 3); // 'move' has complexity 3 + + // Test position() getter (lines 82-83) + assert_eq!(tags[0].position(), 10); // First tag at offset 10 + assert_eq!(tags[1].position(), 13); // Second tag starts at 10 + 3 characters + } + + #[test] + fn test_parse_override_block_empty_tag_name() { + let mut tags = Vec::new(); + let mut diagnostics = Vec::new(); + + // Test with empty tag name (just \\ followed by another tag) + parse_override_block("\\\\b1", 0, &mut tags, &mut diagnostics); + + // Should have one diagnostic for empty override + assert!(!diagnostics.is_empty()); + assert!(matches!(diagnostics[0].kind, DiagnosticKind::EmptyOverride)); + } + + #[test] + fn test_parse_override_block_valid_tag_creation() { + let mut tags = Vec::new(); + let mut diagnostics = Vec::new(); + + // Test valid tag creation (lines 142-145) + parse_override_block("\\c&H00FF00&", 5, &mut tags, &mut diagnostics); + + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].name(), "c"); + assert_eq!(tags[0].args(), "&H00FF00&"); + assert_eq!(tags[0].position(), 5); + assert_eq!(diagnostics.len(), 0); + } + + #[test] + fn test_parse_override_block_non_ascii_tag_args() { + let mut tags = Vec::new(); + let mut diagnostics = Vec::new(); + parse_override_block("\\fn微软雅黑", 0, &mut tags, &mut diagnostics); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].complexity(), 1); + assert_eq!(tags[0].name(), "fn"); + assert_eq!(tags[0].args(), "微软雅黑"); + + let mut tags = Vec::new(); + let mut diagnostics = Vec::new(); + parse_override_block_with_registry("\\fn微软雅黑", 0, &mut tags, &mut diagnostics, None); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].complexity(), 1); + assert_eq!(tags[0].name(), "fn"); + assert_eq!(tags[0].args(), "微软雅黑"); + } +} diff --git a/crates/ass-core/analysis/events/text_analysis.rs b/crates/ass-core/analysis/events/text_analysis.rs new file mode 100644 index 0000000..14d3994 --- /dev/null +++ b/crates/ass-core/analysis/events/text_analysis.rs @@ -0,0 +1,691 @@ +//! Text content analysis for ASS dialogue events +//! +//! Provides comprehensive analysis of dialogue text including override tag parsing, +//! Unicode complexity detection, and character counting. Uses zero-copy design +//! with lifetime-generic references to original text. +//! +//! # Features +//! +//! - Override tag extraction and complexity scoring +//! - Plain text extraction (tags removed) +//! - Unicode bidirectional text detection +//! - Character and line counting +//! - Zero-copy tag argument references +//! +//! # Performance +//! +//! - Target: <0.5ms per event text analysis +//! - Memory: Minimal allocations via string slices +//! - Unicode: Efficient detection without full normalization + +use crate::{ + analysis::events::tags::{parse_override_block, DiagnosticKind, OverrideTag, TagDiagnostic}, + utils::{errors::resource::check_depth_limit, CoreError}, + Result, +}; + +#[cfg(feature = "plugins")] +use crate::analysis::events::tags::parse_override_block_with_registry; + +#[cfg(feature = "plugins")] +use crate::plugin::ExtensionRegistry; +use alloc::{string::String, vec::Vec}; + +/// Analysis results for dialogue text content +/// +/// Contains extracted plain text, override tag information, and Unicode +/// complexity indicators. Uses zero-copy references where possible. +#[derive(Debug, Clone)] +pub struct TextAnalysis<'a> { + /// Plain text with override tags removed + plain_text: String, + /// Unicode character count + char_count: usize, + /// Line count after processing linebreaks + line_count: usize, + /// Contains bidirectional text (RTL scripts) + has_bidi_text: bool, + /// Contains complex Unicode beyond basic Latin + has_complex_unicode: bool, + /// Parsed override tags + override_tags: Vec>, + /// Parse diagnostics collected during analysis + parse_diagnostics: Vec>, +} + +impl<'a> TextAnalysis<'a> { + /// Analyze dialogue text content comprehensively + /// + /// Extracts plain text, parses override tags, and analyzes Unicode + /// complexity. Uses zero-copy references for tag arguments. + /// + /// # Arguments + /// + /// * `text` - Original dialogue text with potential override tags + /// + /// # Returns + /// + /// Complete text analysis results or parsing error. + /// + /// # Example + /// + /// ```rust + /// # use ass_core::analysis::events::text_analysis::TextAnalysis; + /// let text = "Hello {\\b1}world{\\b0}!"; + /// let analysis = TextAnalysis::analyze(text)?; + /// assert_eq!(analysis.plain_text(), "Hello world!"); + /// assert_eq!(analysis.override_tags().len(), 2); + /// # Ok::<(), Box>(()) + /// ``` + /// + /// # Errors + /// + /// Returns an error if text parsing fails or contains invalid override tags. + pub fn analyze(text: &'a str) -> Result { + #[cfg(feature = "plugins")] + return Self::analyze_with_registry(text, None); + #[cfg(not(feature = "plugins"))] + return Self::analyze_impl(text); + } + + /// Analyze dialogue text content with extension registry support + /// + /// Same as [`analyze`](Self::analyze) but allows custom tag handlers via registry. + /// Unhandled tags fall back to standard processing. + /// + /// # Arguments + /// + /// * `text` - Original dialogue text with potential override tags + /// * `registry` - Optional registry for custom tag handlers + /// + /// # Returns + /// + /// Complete text analysis results or parsing error. + /// + /// # Errors + /// + /// Returns an error if text parsing fails or contains invalid override tags. + #[cfg(feature = "plugins")] + pub fn analyze_with_registry( + text: &'a str, + registry: Option<&ExtensionRegistry>, + ) -> Result { + Self::analyze_impl_with_registry(text, registry) + } + + /// Internal implementation without plugins support + #[cfg(not(feature = "plugins"))] + fn analyze_impl(text: &'a str) -> Result { + Self::analyze_impl_with_registry(text) + } + + /// Internal implementation of analysis with optional registry support + fn analyze_impl_with_registry( + text: &'a str, + #[cfg(feature = "plugins")] registry: Option<&ExtensionRegistry>, + ) -> Result { + const MAX_BRACE_DEPTH: usize = 100; // Prevent DoS with deeply nested braces + + let mut override_tags = Vec::new(); + let mut parse_diagnostics = Vec::new(); + + let mut plain_text = String::new(); + let mut position = 0; + let mut drawing_mode = false; + + let mut chars = text.chars(); + while let Some(ch) = chars.next() { + if ch == '{' { + let mut brace_count = 1; + let tag_start = position + ch.len_utf8(); + + for inner_ch in chars.by_ref() { + position += inner_ch.len_utf8(); + + if inner_ch == '{' { + brace_count += 1; + // Check for excessive nesting depth to prevent DoS + if check_depth_limit(brace_count, MAX_BRACE_DEPTH).is_err() { + return Err(CoreError::parse("Maximum brace nesting depth exceeded")); + } + } else if inner_ch == '}' { + brace_count -= 1; + if brace_count == 0 { + break; + } + } + } + + if position > tag_start { + let tag_content = &text[tag_start..position]; + + #[cfg(feature = "plugins")] + if let Some(registry) = registry { + parse_override_block_with_registry( + tag_content, + tag_start, + &mut override_tags, + &mut parse_diagnostics, + Some(registry), + ); + } else { + parse_override_block( + tag_content, + tag_start, + &mut override_tags, + &mut parse_diagnostics, + ); + } + + #[cfg(not(feature = "plugins"))] + parse_override_block( + tag_content, + tag_start, + &mut override_tags, + &mut parse_diagnostics, + ); + + // Check for drawing mode changes in this tag block + drawing_mode = Self::update_drawing_mode(tag_content, drawing_mode); + } else { + parse_diagnostics.push(TagDiagnostic { + span: &text[tag_start..position.max(tag_start + 1)], + offset: tag_start, + kind: DiagnosticKind::EmptyOverride, + }); + } + } else if ch == '\\' { + if let Some(next_ch) = chars.next() { + position += next_ch.len_utf8(); + match next_ch { + 'n' | 'N' => { + if !drawing_mode { + plain_text.push('\n'); + } + } + 'h' => { + if !drawing_mode { + plain_text.push('\u{00A0}'); + } + } + _ => { + if !drawing_mode { + plain_text.push(ch); + plain_text.push(next_ch); + } + } + } + } + } else if !drawing_mode { + plain_text.push(ch); + } + + position += ch.len_utf8(); + } + + let char_count = plain_text.chars().count(); + let line_count = Self::count_lines(&plain_text); + let has_bidi_text = Self::detect_bidi_text(&plain_text); + let has_complex_unicode = Self::detect_complex_unicode(&plain_text); + + Ok(Self { + plain_text, + char_count, + line_count, + has_bidi_text, + has_complex_unicode, + override_tags, + parse_diagnostics, + }) + } + + /// Get plain text without override tags + #[must_use] + pub fn plain_text(&self) -> &str { + &self.plain_text + } + + /// Get Unicode character count + #[must_use] + pub const fn char_count(&self) -> usize { + self.char_count + } + + /// Get line count after processing linebreaks + #[must_use] + pub const fn line_count(&self) -> usize { + self.line_count + } + + /// Check if text contains bidirectional content + #[must_use] + pub const fn has_bidi_text(&self) -> bool { + self.has_bidi_text + } + + /// Check if text contains complex Unicode beyond basic Latin + #[must_use] + pub const fn has_complex_unicode(&self) -> bool { + self.has_complex_unicode + } + + /// Get parsed override tags + #[must_use] + pub fn override_tags(&self) -> &[OverrideTag<'a>] { + &self.override_tags + } + + /// Get parse diagnostics collected during analysis + #[must_use] + pub fn diagnostics(&self) -> &[TagDiagnostic<'a>] { + &self.parse_diagnostics + } + + /// Update drawing mode state based on override tag content + fn update_drawing_mode(tag_content: &str, current_mode: bool) -> bool { + let mut pos = 0; + let chars: Vec = tag_content.chars().collect(); + let mut drawing_mode = current_mode; + + while pos < chars.len() { + if chars[pos] == '\\' && pos + 1 < chars.len() && chars[pos + 1] == 'p' { + pos += 2; + let mut number_str = String::new(); + + while pos < chars.len() && (chars[pos].is_ascii_digit() || chars[pos] == '-') { + number_str.push(chars[pos]); + pos += 1; + } + + if let Ok(p_value) = number_str.parse::() { + drawing_mode = p_value > 0; + } + } else { + pos += 1; + } + } + + drawing_mode + } + + /// Count lines correctly, handling empty lines and trailing newlines + fn count_lines(text: &str) -> usize { + if text.is_empty() { + return 1; + } + + // For ASS subtitles, count newlines and add 1, but handle special cases + let newline_count = text.chars().filter(|&ch| ch == '\n').count(); + + if newline_count == 0 { + // No newlines means 1 line + 1 + } else if text.trim_end_matches('\n').is_empty() { + // Text is only newlines - each newline creates a line boundary + newline_count + 1 + } else { + // Text has content - trailing newlines don't create additional lines + // Use lines() count which handles this correctly + text.lines().count().max(1) + } + } + + /// Detect bidirectional text (RTL scripts) + fn detect_bidi_text(text: &str) -> bool { + text.chars().any(|ch| matches!(ch as u32, 0x0590..=0x05FF | 0x0600..=0x06FF | 0x0750..=0x077F | 0x08A0..=0x08FF)) + } + + /// Detect complex Unicode beyond basic Latin + fn detect_complex_unicode(text: &str) -> bool { + text.chars().any(|ch| { + let code = ch as u32; + code > 0x00FF || matches!(code, 0x0000..=0x001F | 0x007F..=0x009F | 0x200C..=0x200D | 0x2060..=0x206F) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(not(feature = "std"))] + use alloc::string::ToString; + + #[test] + fn text_analysis_simple_text() { + let text = "Hello world!"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Hello world!"); + assert_eq!(analysis.char_count(), 12); + assert_eq!(analysis.line_count(), 1); + assert!(!analysis.has_bidi_text()); + assert!(!analysis.has_complex_unicode()); + assert!(analysis.override_tags().is_empty()); + assert!(analysis.diagnostics().is_empty()); + } + + #[test] + fn text_analysis_with_override_tags() { + let text = "Hello {\\b1}bold{\\b0} world!"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Hello bold world!"); + assert_eq!(analysis.char_count(), 17); + assert_eq!(analysis.line_count(), 1); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_nested_braces() { + let text = "Text {\\pos(100,{\\some}200)} more text"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Text more text"); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_line_breaks() { + let text = "First line\\NSecond line\\nThird line"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "First line\nSecond line\nThird line"); + assert_eq!(analysis.line_count(), 3); + } + + #[test] + fn text_analysis_hard_spaces() { + let text = "Text\\hwith\\hhard\\hspaces"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!( + analysis.plain_text(), + "Text\u{00A0}with\u{00A0}hard\u{00A0}spaces" + ); + } + + #[test] + fn text_analysis_mixed_escapes() { + let text = "Line 1\\NLine 2\\hspace\\nLine 3"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Line 1\nLine 2\u{00A0}space\nLine 3"); + assert_eq!(analysis.line_count(), 3); + } + + #[test] + fn text_analysis_bidi_text_arabic() { + let text = "Hello مرحبا world"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert!(analysis.has_bidi_text()); + assert!(analysis.has_complex_unicode()); + } + + #[test] + fn text_analysis_bidi_text_hebrew() { + let text = "Hello שלום world"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert!(analysis.has_bidi_text()); + assert!(analysis.has_complex_unicode()); + } + + #[test] + fn text_analysis_complex_unicode_emoji() { + let text = "Hello 🌍 world"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert!(!analysis.has_bidi_text()); + assert!(analysis.has_complex_unicode()); + } + + #[test] + fn text_analysis_complex_unicode_control_chars() { + let text = "Text\u{200C}with\u{200D}controls"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert!(analysis.has_complex_unicode()); + } + + #[test] + fn text_analysis_basic_latin_only() { + let text = "Basic ASCII text 123!@#"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert!(!analysis.has_bidi_text()); + assert!(!analysis.has_complex_unicode()); + } + + #[test] + fn text_analysis_extended_latin() { + let text = "Café naïve résumé"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert!(!analysis.has_bidi_text()); + assert!(!analysis.has_complex_unicode()); // These are still in Latin-1 range + } + + #[test] + fn text_analysis_empty_override_blocks() { + let text = "Text {} more text"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Text more text"); + // Should have diagnostic for empty override + assert!(!analysis.diagnostics().is_empty()); + } + + #[test] + fn text_analysis_unmatched_braces() { + let text = "Text {\\b1 unmatched"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Text "); + // Should handle unmatched braces gracefully + } + + #[test] + fn text_analysis_multiple_override_blocks() { + let text = "{\\b1}Bold{\\b0} and {\\i1}italic{\\i0} text"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Bold and italic text"); + assert_eq!(analysis.override_tags().len(), 4); + } + + #[test] + fn text_analysis_complex_tags() { + let text = "{\\move(0,0,100,100)}{\\t(0,1000,\\fscx120)}{\\fade(255,0,0,0,800,900,1000)}Animated text"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Animated text"); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_drawing_commands() { + let text = "{\\p1}m 0 0 l 100 0 100 100 0 100{\\p0}Square"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Square"); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_color_tags() { + let text = "{\\c&H0000FF&}Red text{\\c} and {\\1c&H00FF00&}green text"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Red text and green text"); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_mixed_content() { + let text = "Start {\\b1}bold\\N{\\i1}italic{\\i0}{\\b0}\\hnormal end"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!( + analysis.plain_text(), + "Start bold\nitalic\u{00A0}normal end" + ); + assert_eq!(analysis.line_count(), 2); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_whitespace_only() { + let text = " \t\n "; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), " \t\n "); + assert_eq!(analysis.char_count(), 7); + assert_eq!(analysis.line_count(), 2); + } + + #[test] + fn text_analysis_empty_text() { + let text = ""; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), ""); + assert_eq!(analysis.char_count(), 0); + assert_eq!(analysis.line_count(), 1); // Minimum 1 line + assert!(analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_only_override_tags() { + let text = "{\\b1}{\\i1}{\\u1}"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), ""); + assert_eq!(analysis.char_count(), 0); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_escape_sequences() { + let text = "Test`[Events]`backslash and \\{brace and \\}close"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + // These should be treated as literal characters, not escape sequences + assert_eq!( + analysis.plain_text(), + "Test`[Events]`backslash and \\{brace and \\}close" + ); + } + + #[test] + fn text_analysis_karaoke_tags() { + let text = "{\\k50}Ka{\\k30}ra{\\k70}o{\\k40}ke"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Karaoke"); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_position_and_rotation() { + let text = "{\\pos(320,240)}{\\frz45}Rotated positioned text"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + assert_eq!(analysis.plain_text(), "Rotated positioned text"); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_very_long_text() { + let text = "A".repeat(1000); + let analysis = TextAnalysis::analyze(&text).unwrap(); + + assert_eq!(analysis.char_count(), 1000); + assert_eq!(analysis.plain_text().len(), 1000); + } + + #[test] + fn text_analysis_line_count_edge_cases() { + // Text ending with newline + let text1 = "Line 1\\nLine 2\\n"; + let analysis1 = TextAnalysis::analyze(text1).unwrap(); + assert_eq!(analysis1.line_count(), 2); + + // Multiple consecutive newlines + let text2 = "Line 1\\n\\n\\nLine 2"; + let analysis2 = TextAnalysis::analyze(text2).unwrap(); + assert_eq!(analysis2.line_count(), 4); + + // Only newlines + let text3 = "\\n\\N\\n"; + let analysis3 = TextAnalysis::analyze(text3).unwrap(); + assert_eq!(analysis3.line_count(), 4); + } + + #[test] + fn text_analysis_excessive_brace_nesting() { + // Create deeply nested braces to trigger depth limit error + let mut text = String::new(); + for _ in 0..110 { + text.push('{'); + } + text.push_str("\\b1"); + for _ in 0..110 { + text.push('}'); + } + + let result = TextAnalysis::analyze(&text); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Maximum brace nesting depth exceeded")); + } + + #[test] + fn text_analysis_drawing_mode_escape_sequences() { + // Test escape sequences in drawing mode - they should not be processed + let text = "{\\p1}Line1\\nLine2\\hSpace\\NNewline{\\p0}Normal\\ntext"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + // In drawing mode, text is ignored entirely from plain_text + // After {p0}, normal processing resumes + assert_eq!(analysis.plain_text(), "Normal\ntext"); + assert!(!analysis.override_tags().is_empty()); + } + + #[test] + fn text_analysis_drawing_mode_p_value_parsing() { + // Test various p values to trigger drawing mode logic + let text1 = "{\\p0}Not drawing mode"; + let analysis1 = TextAnalysis::analyze(text1).unwrap(); + assert_eq!(analysis1.plain_text(), "Not drawing mode"); + + let text2 = "{\\p1}Drawing mode"; + let analysis2 = TextAnalysis::analyze(text2).unwrap(); + assert_eq!(analysis2.plain_text(), ""); // Drawing mode excludes text + + let text3 = "{\\p5}Also drawing mode"; + let analysis3 = TextAnalysis::analyze(text3).unwrap(); + assert_eq!(analysis3.plain_text(), ""); // Drawing mode excludes text + } + + #[test] + fn text_analysis_line_count_only_newlines() { + // Test line counting when text is only newlines (line 252) + let text = "\n\n\n"; + let analysis = TextAnalysis::analyze(text).unwrap(); + assert_eq!(analysis.line_count(), 4); // 3 newlines = 4 lines + } + + #[test] + fn text_analysis_drawing_mode_mixed_escapes() { + // Test all escape sequence types in drawing mode + let text = "{\\p1}Start\\nNew\\NLine\\hHard{\\p0}End\\nNormal"; + let analysis = TextAnalysis::analyze(text).unwrap(); + + // Drawing mode excludes all text, normal mode processes escape sequences + assert_eq!(analysis.plain_text(), "End\nNormal"); + assert!(!analysis.override_tags().is_empty()); + } +} diff --git a/crates/ass-core/analysis/events/utils.rs b/crates/ass-core/analysis/events/utils.rs new file mode 100644 index 0000000..2832d21 --- /dev/null +++ b/crates/ass-core/analysis/events/utils.rs @@ -0,0 +1,375 @@ +//! Utility functions for dialogue event analysis +//! +//! Provides common operations for collections of dialogue events including +//! sorting, duration calculations, and overlap detection. Optimized for +//! performance with large event collections. +//! +//! # Features +//! +//! - Efficient sorting by timing with stable ordering +//! - Total duration calculation across event collections +//! - Overlap detection delegation to efficient algorithms +//! - Zero-allocation operations where possible +//! +//! # Performance +//! +//! - Sorting: O(n log n) with optimized comparison +//! - Duration: O(n) single pass with min/max tracking +//! - Overlap detection: O(n log n) via sweep-line algorithm + +use crate::analysis::events::{dialogue_info::DialogueInfo, overlap::find_overlapping_event_refs}; +use crate::parser::Event; +use alloc::vec::Vec; +use core::cmp::Ordering; + +/// Find overlapping dialogue events using efficient timing analysis +/// +/// Returns pairs of event indices that have overlapping timing. +/// Delegates to the efficient O(n log n) sweep-line algorithm for optimal performance. +/// +/// # Arguments +/// +/// * `events` - Slice of `DialogueInfo` to analyze for overlaps +/// +/// # Returns +/// +/// Vector of (index1, index2) pairs representing overlapping events. +/// +/// # Example +/// +/// ```rust +/// # use ass_core::analysis::events::utils::find_overlapping_dialogue_events; +/// # use ass_core::analysis::events::dialogue_info::DialogueInfo; +/// # use ass_core::parser::Event; +/// let event1 = Event { start: "0:00:00.00", end: "0:00:05.00", ..Default::default() }; +/// let event2 = Event { start: "0:00:03.00", end: "0:00:08.00", ..Default::default() }; +/// let events = vec![ +/// DialogueInfo::analyze(&event1)?, +/// DialogueInfo::analyze(&event2)?, +/// ]; +/// +/// let overlaps = find_overlapping_dialogue_events(&events); +/// assert_eq!(overlaps.len(), 1); +/// # Ok::<(), Box>(()) +/// ``` +#[must_use] +pub fn find_overlapping_dialogue_events(events: &[DialogueInfo<'_>]) -> Vec<(usize, usize)> { + let event_refs: Vec<&Event> = events + .iter() + .map(super::dialogue_info::DialogueInfo::event) + .collect(); + find_overlapping_event_refs(&event_refs).unwrap_or_else(|_| Vec::new()) +} + +/// Count overlapping dialogue events efficiently +/// +/// Convenience wrapper that returns only the count of overlapping pairs. +/// More memory efficient when the specific overlap pairs aren't needed. +/// +/// # Arguments +/// +/// * `events` - Slice of `DialogueInfo` to check for overlaps +/// +/// # Returns +/// +/// Number of overlapping event pairs found. +#[must_use] +pub fn count_overlapping_dialogue_events(events: &[DialogueInfo<'_>]) -> usize { + find_overlapping_dialogue_events(events).len() +} + +/// Sort events by timing with stable ordering +/// +/// Sorts events by start time first, then by end time for events that +/// start simultaneously. Uses stable sort to preserve relative order +/// of equal elements. +/// +/// # Arguments +/// +/// * `events` - Mutable slice of `DialogueInfo` to sort in-place +/// +/// # Performance +/// +/// O(n log n) time complexity with minimal allocations. +/// Comparison operations are optimized for centisecond timing. +/// +/// # Example +/// +/// ```rust +/// # use ass_core::analysis::events::utils::sort_events_by_time; +/// # use ass_core::analysis::events::dialogue_info::DialogueInfo; +/// # use ass_core::parser::Event; +/// let event1 = Event { +/// start: "0:00:05.00", +/// end: "0:00:10.00", +/// ..Default::default() +/// }; +/// let event2 = Event { +/// start: "0:00:01.00", +/// end: "0:00:06.00", +/// ..Default::default() +/// }; +/// let dialogue_info1 = DialogueInfo::analyze(&event1)?; +/// let dialogue_info2 = DialogueInfo::analyze(&event2)?; +/// let mut events = vec![dialogue_info1, dialogue_info2]; +/// sort_events_by_time(&mut events); +/// // Events are now sorted by start time, then end time +/// # Ok::<(), Box>(()) +/// ``` +pub fn sort_events_by_time(events: &mut [DialogueInfo<'_>]) { + events.sort_by(|a, b| match a.start_time_cs().cmp(&b.start_time_cs()) { + Ordering::Equal => a.end_time_cs().cmp(&b.end_time_cs()), + other => other, + }); +} + +/// Calculate total duration spanning all events +/// +/// Computes the duration from the earliest start time to the latest +/// end time across all events. Returns None for empty collections. +/// +/// # Arguments +/// +/// * `events` - Slice of `DialogueInfo` to analyze +/// +/// # Returns +/// +/// Total duration in centiseconds, or None if no events provided. +/// +/// # Performance +/// +/// Single O(n) pass with iterator optimizations for min/max operations. +/// +/// # Example +/// +/// ```rust +/// # use ass_core::analysis::events::utils::calculate_total_duration; +/// # use ass_core::analysis::events::dialogue_info::DialogueInfo; +/// # use ass_core::parser::Event; +/// let event1 = Event { +/// start: "0:00:01.00", +/// end: "0:00:05.00", +/// ..Default::default() +/// }; +/// let event2 = Event { +/// start: "0:00:03.00", +/// end: "0:00:08.00", +/// ..Default::default() +/// }; +/// let dialogue_info1 = DialogueInfo::analyze(&event1)?; +/// let dialogue_info2 = DialogueInfo::analyze(&event2)?; +/// let events = vec![dialogue_info1, dialogue_info2]; +/// if let Some(duration) = calculate_total_duration(&events) { +/// println!("Total span: {}ms", duration); +/// } +/// # Ok::<(), Box>(()) +/// ``` +#[must_use] +pub fn calculate_total_duration(events: &[DialogueInfo<'_>]) -> Option { + if events.is_empty() { + return None; + } + + let start = events + .iter() + .map(super::dialogue_info::DialogueInfo::start_time_cs) + .min()?; + let end = events + .iter() + .map(super::dialogue_info::DialogueInfo::end_time_cs) + .max()?; + + Some(end - start) +} + +/// Calculate average event duration +/// +/// Computes the mean duration of all events in the collection. +/// Returns None for empty collections. +/// +/// # Arguments +/// +/// * `events` - Slice of `DialogueInfo` to analyze +/// +/// # Returns +/// +/// Average duration in centiseconds, or None if no events. +#[must_use] +pub fn calculate_average_duration(events: &[DialogueInfo<'_>]) -> Option { + if events.is_empty() { + return None; + } + + let total_duration: u32 = events + .iter() + .map(super::dialogue_info::DialogueInfo::duration_cs) + .sum(); + Some(total_duration / u32::try_from(events.len()).unwrap_or(u32::MAX)) +} + +/// Find events within a specific time range +/// +/// Returns indices of events that overlap with the given time range. +/// Useful for temporal filtering and range-based analysis. +/// +/// # Arguments +/// +/// * `events` - Slice of `DialogueInfo` to search +/// * `start_cs` - Range start time in centiseconds +/// * `end_cs` - Range end time in centiseconds +/// +/// # Returns +/// +/// Vector of indices for events overlapping the time range. +#[must_use] +pub fn find_events_in_range(events: &[DialogueInfo<'_>], start_cs: u32, end_cs: u32) -> Vec { + events + .iter() + .enumerate() + .filter_map(|(idx, event)| { + if event.overlaps_time_range(start_cs, end_cs) { + Some(idx) + } else { + None + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::{ast::Span, Event}; + #[cfg(not(feature = "std"))] + use alloc::boxed::Box; + #[cfg(not(feature = "std"))] + use alloc::vec; + #[cfg(not(feature = "std"))] + fn create_test_dialogue_info(start: &'static str, end: &'static str) -> DialogueInfo<'static> { + // Create a static event for the lifetime requirement + // Using Box::leak is acceptable in tests for simplicity + let event = Box::leak(Box::new(Event { + event_type: crate::parser::ast::EventType::Dialogue, + start, + end, + text: "Test", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + })); + DialogueInfo::analyze(event).unwrap() + } + + #[cfg(feature = "std")] + fn create_test_dialogue_info(start: &'static str, end: &'static str) -> DialogueInfo<'static> { + // Create a static event for the lifetime requirement + // Using Box::leak is acceptable in tests for simplicity + let event = Box::leak(Box::new(Event { + event_type: crate::parser::ast::EventType::Dialogue, + start, + end, + text: "Test", + layer: "0", + style: "Default", + name: "", + margin_l: "0", + margin_r: "0", + margin_v: "0", + margin_t: None, + margin_b: None, + effect: "", + span: Span::new(0, 0, 0, 0), + })); + DialogueInfo::analyze(event).unwrap() + } + + #[test] + fn empty_events_no_overlaps() { + let events = vec![]; + assert_eq!(find_overlapping_dialogue_events(&events).len(), 0); + assert_eq!(count_overlapping_dialogue_events(&events), 0); + } + + #[test] + fn calculate_duration_empty() { + let events = vec![]; + assert_eq!(calculate_total_duration(&events), None); + assert_eq!(calculate_average_duration(&events), None); + } + + #[test] + fn calculate_duration_single_event() { + let events = vec![create_test_dialogue_info("0:00:00.00", "0:00:05.00")]; + assert_eq!(calculate_total_duration(&events), Some(500)); // 5 seconds = 500cs + assert_eq!(calculate_average_duration(&events), Some(500)); + } + + #[test] + fn calculate_duration_multiple_events() { + let events = vec![ + create_test_dialogue_info("0:00:00.00", "0:00:05.00"), + create_test_dialogue_info("0:00:03.00", "0:00:08.00"), + create_test_dialogue_info("0:00:10.00", "0:00:15.00"), + ]; + + // Total: 0:00:00.00 to 0:00:15.00 = 1500cs + assert_eq!(calculate_total_duration(&events), Some(1500)); + + // Average: (500 + 500 + 500) / 3 = 500cs + assert_eq!(calculate_average_duration(&events), Some(500)); + } + + #[test] + fn sort_events_maintains_order() { + let mut events = vec![ + create_test_dialogue_info("0:00:05.00", "0:00:10.00"), + create_test_dialogue_info("0:00:00.00", "0:00:05.00"), + create_test_dialogue_info("0:00:02.00", "0:00:07.00"), + ]; + + sort_events_by_time(&mut events); + + assert_eq!(events[0].start_time_cs(), 0); // 0:00:00.00 + assert_eq!(events[1].start_time_cs(), 200); // 0:00:02.00 + assert_eq!(events[2].start_time_cs(), 500); // 0:00:05.00 + } + + #[test] + fn find_events_in_range_filters_correctly() { + let events = vec![ + create_test_dialogue_info("0:00:00.00", "0:00:05.00"), // 0-500cs + create_test_dialogue_info("0:00:03.00", "0:00:08.00"), // 300-800cs + create_test_dialogue_info("0:00:10.00", "0:00:15.00"), // 1000-1500cs + ]; + + let indices = find_events_in_range(&events, 250, 600); // 2.5s to 6s + assert_eq!(indices, vec![0, 1]); // First two events overlap this range + } + + #[test] + fn sort_events_same_start_time() { + // Test sorting when events have same start time (covers line 121) + let mut events = vec![ + create_test_dialogue_info("0:00:05.00", "0:00:10.00"), // Same start, longer duration + create_test_dialogue_info("0:00:05.00", "0:00:08.00"), // Same start, shorter duration + create_test_dialogue_info("0:00:05.00", "0:00:12.00"), // Same start, longest duration + ]; + + sort_events_by_time(&mut events); + + // All should have same start time but be sorted by end time + assert_eq!(events[0].start_time_cs(), 500); + assert_eq!(events[0].end_time_cs(), 800); // Shortest duration first + assert_eq!(events[1].start_time_cs(), 500); + assert_eq!(events[1].end_time_cs(), 1000); // Medium duration second + assert_eq!(events[2].start_time_cs(), 500); + assert_eq!(events[2].end_time_cs(), 1200); // Longest duration last + } +} diff --git a/crates/ass-core/analysis/linting/mod.rs b/crates/ass-core/analysis/linting/mod.rs new file mode 100644 index 0000000..9f8cc27 --- /dev/null +++ b/crates/ass-core/analysis/linting/mod.rs @@ -0,0 +1,611 @@ +//! Linting and validation for ASS subtitle scripts. +//! +//! Provides comprehensive linting capabilities to detect common issues, spec violations, +//! and performance problems in ASS scripts. Designed for editor integration with +//! configurable severity levels and extensible rule system. +//! +//! # Features +//! +//! - **Comprehensive validation**: Timing, styling, formatting, and spec compliance +//! - **Configurable severity**: Error, warning, info, and hint levels +//! - **Extensible rules**: Trait-based system for custom linting rules +//! - **Performance optimized**: Zero-copy analysis with <1ms per rule +//! - **Editor integration**: Rich diagnostic information with precise locations +//! +//! # Built-in Rules +//! +//! - Timing validation: Overlaps, negative durations, unrealistic timing +//! - Style validation: Missing styles, invalid colors, font issues +//! - Text validation: Encoding issues, malformed tags, accessibility +//! - Performance: Complex animations, large fonts, excessive overlaps +//! - Spec compliance: Invalid sections, deprecated features, compatibility + +use crate::{ + analysis::{AnalysisConfig, ScriptAnalysis}, + parser::Script, + Result, +}; +use alloc::{string::String, vec::Vec}; +use core::fmt; + +pub mod rules; + +pub use rules::BuiltinRules; + +/// Severity level for lint issues. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum IssueSeverity { + /// Informational message - no action required + Info, + /// Hint for improvement - optional fix + Hint, + /// Warning - should be addressed but not critical + Warning, + /// Error - must be fixed for proper functionality + Error, + /// Critical error - script may not work at all + Critical, +} + +impl fmt::Display for IssueSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Info => write!(f, "info"), + Self::Hint => write!(f, "hint"), + Self::Warning => write!(f, "warning"), + Self::Error => write!(f, "error"), + Self::Critical => write!(f, "critical"), + } + } +} + +/// Category of lint issue. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IssueCategory { + /// Timing-related issues + Timing, + /// Style definition problems + Styling, + /// Text content issues + Content, + /// Performance concerns + Performance, + /// Spec compliance violations + Compliance, + /// Accessibility concerns + Accessibility, + /// Encoding or character issues + Encoding, +} + +impl fmt::Display for IssueCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Timing => write!(f, "timing"), + Self::Styling => write!(f, "styling"), + Self::Content => write!(f, "content"), + Self::Performance => write!(f, "performance"), + Self::Compliance => write!(f, "compliance"), + Self::Accessibility => write!(f, "accessibility"), + Self::Encoding => write!(f, "encoding"), + } + } +} + +/// Location information for a lint issue. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IssueLocation { + /// Line number (1-based) + pub line: usize, + /// Column number (1-based) + pub column: usize, + /// Byte offset in source + pub offset: usize, + /// Length of the problematic span + pub length: usize, + /// The problematic text span + pub span: String, +} + +/// A single lint issue found in the script. +#[derive(Debug, Clone)] +pub struct LintIssue { + /// Severity level + severity: IssueSeverity, + /// Category of issue + category: IssueCategory, + /// Human-readable message + message: String, + /// Optional detailed description + description: Option, + /// Location in source (if available) + location: Option, + /// Rule ID that generated this issue + rule_id: &'static str, + /// Suggested fix (if available) + suggested_fix: Option, +} + +impl LintIssue { + /// Create a new lint issue. + #[must_use] + pub const fn new( + severity: IssueSeverity, + category: IssueCategory, + rule_id: &'static str, + message: String, + ) -> Self { + Self { + severity, + category, + message, + description: None, + location: None, + rule_id, + suggested_fix: None, + } + } + + /// Add detailed description. + #[must_use] + pub fn with_description(mut self, description: String) -> Self { + self.description = Some(description); + self + } + + /// Add location information. + #[must_use] + pub fn with_location(mut self, location: IssueLocation) -> Self { + self.location = Some(location); + self + } + + /// Add suggested fix. + #[must_use] + pub fn with_suggested_fix(mut self, fix: String) -> Self { + self.suggested_fix = Some(fix); + self + } + + /// Get severity level. + #[must_use] + pub const fn severity(&self) -> IssueSeverity { + self.severity + } + + /// Get issue category. + #[must_use] + pub const fn category(&self) -> IssueCategory { + self.category + } + + /// Get issue message. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } + + /// Get detailed description. + #[must_use] + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Get location information. + #[must_use] + pub const fn location(&self) -> Option<&IssueLocation> { + self.location.as_ref() + } + + /// Get rule ID. + #[must_use] + pub const fn rule_id(&self) -> &'static str { + self.rule_id + } + + /// Get suggested fix. + #[must_use] + pub fn suggested_fix(&self) -> Option<&str> { + self.suggested_fix.as_deref() + } +} + +/// Configuration for linting behavior. +#[derive(Debug, Clone)] +pub struct LintConfig { + /// Minimum severity level to report + pub min_severity: IssueSeverity, + /// Maximum number of issues to report (0 = unlimited) + pub max_issues: usize, + /// Enable strict compliance mode + pub strict_mode: bool, + /// Enabled rule IDs (empty = all enabled) + pub enabled_rules: Vec<&'static str>, + /// Disabled rule IDs + pub disabled_rules: Vec<&'static str>, +} + +impl Default for LintConfig { + fn default() -> Self { + Self { + min_severity: IssueSeverity::Info, + max_issues: 0, // Unlimited + strict_mode: false, + enabled_rules: Vec::new(), + disabled_rules: Vec::new(), + } + } +} + +impl LintConfig { + /// Set minimum severity level. + #[must_use] + pub const fn with_min_severity(mut self, severity: IssueSeverity) -> Self { + self.min_severity = severity; + self + } + + /// Set maximum number of issues. + #[must_use] + pub const fn with_max_issues(mut self, max: usize) -> Self { + self.max_issues = max; + self + } + + /// Enable strict compliance checking. + #[must_use] + pub const fn with_strict_compliance(mut self, enabled: bool) -> Self { + self.strict_mode = enabled; + self + } + + /// Check if a rule is enabled. + #[must_use] + pub fn is_rule_enabled(&self, rule_id: &str) -> bool { + if self.disabled_rules.contains(&rule_id) { + return false; + } + self.enabled_rules.is_empty() || self.enabled_rules.contains(&rule_id) + } + + /// Check if severity should be reported. + #[must_use] + pub fn should_report_severity(&self, severity: IssueSeverity) -> bool { + severity >= self.min_severity + } +} + +/// Trait for implementing custom lint rules. +pub trait LintRule: Send + Sync { + /// Unique identifier for this rule. + fn id(&self) -> &'static str; + + /// Human-readable name. + fn name(&self) -> &'static str; + + /// Rule description. + fn description(&self) -> &'static str; + + /// Default severity level. + fn default_severity(&self) -> IssueSeverity; + + /// Issue category this rule checks for. + fn category(&self) -> IssueCategory; + + /// Check script and return issues. + fn check_script(&self, analysis: &ScriptAnalysis) -> Vec; +} + +/// Lint a script with the given configuration. +/// Lint script with existing analysis +/// +/// Runs all enabled rules against the provided analysis and returns found issues, +/// respecting the configuration limits and filters. +/// Lint script using existing analysis +/// +/// # Errors +/// +/// Returns an error if linting rule execution fails. +pub fn lint_script_with_analysis( + analysis: &ScriptAnalysis, + config: &LintConfig, +) -> Result> { + let mut issues = Vec::new(); + let rules = BuiltinRules::all_rules(); + + for rule in rules { + if !config.is_rule_enabled(rule.id()) { + continue; + } + + let mut rule_issues = rule.check_script(analysis); + rule_issues.retain(|issue| config.should_report_severity(issue.severity())); + + issues.extend(rule_issues); + + if config.max_issues > 0 && issues.len() >= config.max_issues { + issues.truncate(config.max_issues); + break; + } + } + + Ok(issues) +} + +/// Lint script with configuration +/// +/// Creates a minimal analysis without linting, then runs all enabled rules +/// against the script and returns found issues, respecting the configuration +/// limits and filters. +/// +/// # Errors +/// +/// Returns an error if script analysis or linting rule execution fails. +pub fn lint_script(script: &Script, config: &LintConfig) -> Result> { + // Create analysis without linting to avoid circular dependency + let mut analysis = ScriptAnalysis { + script, + lint_issues: Vec::new(), + resolved_styles: Vec::new(), + dialogue_info: Vec::new(), + config: AnalysisConfig::default(), + #[cfg(feature = "plugins")] + registry: None, + }; + + // Run only style resolution and event analysis (no linting) + analysis.resolve_all_styles(); + analysis.analyze_events(); + + // Now run linting with the prepared analysis + lint_script_with_analysis(&analysis, config) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::Script; + #[cfg(not(feature = "std"))] + use alloc::string::ToString; + + #[test] + fn issue_severity_display() { + assert_eq!(IssueSeverity::Info.to_string(), "info"); + assert_eq!(IssueSeverity::Hint.to_string(), "hint"); + assert_eq!(IssueSeverity::Warning.to_string(), "warning"); + assert_eq!(IssueSeverity::Error.to_string(), "error"); + assert_eq!(IssueSeverity::Critical.to_string(), "critical"); + } + + #[test] + fn issue_severity_ordering() { + assert!(IssueSeverity::Info < IssueSeverity::Hint); + assert!(IssueSeverity::Hint < IssueSeverity::Warning); + assert!(IssueSeverity::Warning < IssueSeverity::Error); + assert!(IssueSeverity::Error < IssueSeverity::Critical); + } + + #[test] + fn issue_category_display() { + assert_eq!(IssueCategory::Timing.to_string(), "timing"); + assert_eq!(IssueCategory::Styling.to_string(), "styling"); + assert_eq!(IssueCategory::Content.to_string(), "content"); + assert_eq!(IssueCategory::Performance.to_string(), "performance"); + assert_eq!(IssueCategory::Compliance.to_string(), "compliance"); + assert_eq!(IssueCategory::Accessibility.to_string(), "accessibility"); + assert_eq!(IssueCategory::Encoding.to_string(), "encoding"); + } + + #[test] + fn issue_location_creation() { + let location = IssueLocation { + line: 42, + column: 10, + offset: 1000, + length: 5, + span: "error".to_string(), + }; + + assert_eq!(location.line, 42); + assert_eq!(location.column, 10); + assert_eq!(location.offset, 1000); + assert_eq!(location.length, 5); + assert_eq!(location.span, "error"); + } + + #[test] + fn lint_issue_creation() { + let issue = LintIssue::new( + IssueSeverity::Warning, + IssueCategory::Timing, + "test_rule", + "Test message".to_string(), + ); + + assert_eq!(issue.severity(), IssueSeverity::Warning); + assert_eq!(issue.category(), IssueCategory::Timing); + assert_eq!(issue.message(), "Test message"); + assert_eq!(issue.rule_id(), "test_rule"); + assert!(issue.description().is_none()); + assert!(issue.location().is_none()); + assert!(issue.suggested_fix().is_none()); + } + + #[test] + fn lint_issue_with_description() { + let issue = LintIssue::new( + IssueSeverity::Error, + IssueCategory::Styling, + "style_rule", + "Style error".to_string(), + ) + .with_description("Detailed description".to_string()); + + assert_eq!(issue.description(), Some("Detailed description")); + } + + #[test] + fn lint_issue_with_location() { + let location = IssueLocation { + line: 5, + column: 2, + offset: 100, + length: 3, + span: "bad".to_string(), + }; + + let issue = LintIssue::new( + IssueSeverity::Critical, + IssueCategory::Content, + "content_rule", + "Content error".to_string(), + ) + .with_location(location); + + let loc = issue.location().unwrap(); + assert_eq!(loc.line, 5); + assert_eq!(loc.column, 2); + assert_eq!(loc.span, "bad"); + } + + #[test] + fn lint_issue_with_suggested_fix() { + let issue = LintIssue::new( + IssueSeverity::Hint, + IssueCategory::Performance, + "perf_rule", + "Performance hint".to_string(), + ) + .with_suggested_fix("Use simpler approach".to_string()); + + assert_eq!(issue.suggested_fix(), Some("Use simpler approach")); + } + + #[test] + fn lint_config_default() { + let config = LintConfig::default(); + assert_eq!(config.min_severity, IssueSeverity::Info); + assert_eq!(config.max_issues, 0); + assert!(!config.strict_mode); + assert!(config.enabled_rules.is_empty()); + assert!(config.disabled_rules.is_empty()); + } + + #[test] + fn lint_config_with_min_severity() { + let config = LintConfig::default().with_min_severity(IssueSeverity::Warning); + assert_eq!(config.min_severity, IssueSeverity::Warning); + } + + #[test] + fn lint_config_with_max_issues() { + let config = LintConfig::default().with_max_issues(100); + assert_eq!(config.max_issues, 100); + } + + #[test] + fn lint_config_with_strict_compliance() { + let config = LintConfig::default().with_strict_compliance(true); + assert!(config.strict_mode); + } + + #[test] + fn lint_config_is_rule_enabled_all_disabled() { + let mut config = LintConfig::default(); + config.disabled_rules.push("test_rule"); + + assert!(!config.is_rule_enabled("test_rule")); + assert!(config.is_rule_enabled("other_rule")); + } + + #[test] + fn lint_config_is_rule_enabled_specific_enabled() { + let mut config = LintConfig::default(); + config.enabled_rules.push("test_rule"); + + assert!(config.is_rule_enabled("test_rule")); + assert!(!config.is_rule_enabled("other_rule")); + } + + #[test] + fn lint_config_should_report_severity() { + let config = LintConfig::default().with_min_severity(IssueSeverity::Warning); + + assert!(!config.should_report_severity(IssueSeverity::Info)); + assert!(!config.should_report_severity(IssueSeverity::Hint)); + assert!(config.should_report_severity(IssueSeverity::Warning)); + assert!(config.should_report_severity(IssueSeverity::Error)); + assert!(config.should_report_severity(IssueSeverity::Critical)); + } + + #[test] + fn lint_script_empty_script() { + let script_content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,2,0,2,30,30,30,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"; + + let script = Script::parse(script_content).unwrap(); + let config = LintConfig::default(); + + let issues = lint_script(&script, &config); + assert!(issues.is_ok()); + } + + #[test] + fn lint_script_with_analysis_empty() { + let script_content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,2,0,2,30,30,30,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"; + + let script = Script::parse(script_content).unwrap(); + let analysis = ScriptAnalysis { + script: &script, + lint_issues: Vec::new(), + resolved_styles: Vec::new(), + dialogue_info: Vec::new(), + config: AnalysisConfig::default(), + #[cfg(feature = "plugins")] + registry: None, + }; + + let config = LintConfig::default(); + let issues = lint_script_with_analysis(&analysis, &config); + assert!(issues.is_ok()); + } + + #[test] + fn lint_script_with_max_issues() { + let script_content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,2,0,2,30,30,30,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"; + + let script = Script::parse(script_content).unwrap(); + let config = LintConfig::default().with_max_issues(1); + + let issues = lint_script(&script, &config); + assert!(issues.is_ok()); + if let Ok(issues) = issues { + assert!(issues.len() <= 1); + } + } + + #[test] + fn lint_script_with_disabled_rule() { + // Test to cover the continue statement when rules are disabled (line 318) + let script_content = "[Script Info]\nTitle: Test\n\n[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\nStyle: Default,Arial,16,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,2,0,2,30,30,30,1\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"; + + let script = Script::parse(script_content).unwrap(); + let analysis = ScriptAnalysis { + script: &script, + lint_issues: Vec::new(), + resolved_styles: Vec::new(), + dialogue_info: Vec::new(), + config: AnalysisConfig::default(), + #[cfg(feature = "plugins")] + registry: None, + }; + + // Create config with specific rules disabled to trigger continue path + let mut config = LintConfig::default(); + config.disabled_rules.push("accessibility_contrast"); + config.disabled_rules.push("encoding_format"); + config.disabled_rules.push("invalid_color"); + + let issues = lint_script_with_analysis(&analysis, &config); + assert!(issues.is_ok()); + } +} diff --git a/crates/ass-core/analysis/linting/rules/accessibility.rs b/crates/ass-core/analysis/linting/rules/accessibility.rs new file mode 100644 index 0000000..e5bcc1d --- /dev/null +++ b/crates/ass-core/analysis/linting/rules/accessibility.rs @@ -0,0 +1,313 @@ +//! Accessibility issue detection rule for ASS script linting. +//! +//! Detects potential accessibility issues in subtitle scripts that could +//! make content difficult to read or understand for users with disabilities +//! or reading difficulties. + +use crate::{ + analysis::{ + events::text_analysis::TextAnalysis, + linting::{IssueCategory, IssueSeverity, LintIssue, LintRule}, + ScriptAnalysis, + }, + parser::Section, + utils::parse_ass_time, +}; +use alloc::{format, string::ToString, vec::Vec}; + +/// Rule for detecting accessibility issues in subtitle scripts +/// +/// Analyzes scripts for patterns that may negatively impact accessibility, +/// including reading speed, contrast, and timing issues that could make +/// subtitles difficult to follow for users with various needs. +/// +/// # Accessibility Checks +/// +/// - Display duration: Ensures events are displayed long enough to be readable +/// - Reading speed: Warns about text that appears/disappears too quickly +/// - Flash prevention: Detects rapid style changes that could trigger seizures +/// +/// # Performance +/// +/// - Time complexity: O(n) for n events +/// - Memory: O(1) additional space +/// - Target: <1ms for typical scripts with 1000 events +/// +/// # Example +/// +/// ```rust +/// use ass_core::analysis::linting::rules::accessibility::AccessibilityRule; +/// use ass_core::analysis::linting::LintRule; +/// use ass_core::{Script, ScriptAnalysis}; +/// +/// let script = Script::parse(r#" +/// [Events] +/// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +/// Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text without proper contrast +/// "#)?; +/// +/// let analysis = ScriptAnalysis::analyze(&script)?; +/// let rule = AccessibilityRule; +/// let issues = rule.check_script(&analysis); +/// # Ok::<(), Box>(()) +/// ``` +pub struct AccessibilityRule; + +impl LintRule for AccessibilityRule { + fn id(&self) -> &'static str { + "accessibility" + } + + fn name(&self) -> &'static str { + "Accessibility" + } + + fn description(&self) -> &'static str { + "Detects potential accessibility issues" + } + + fn default_severity(&self) -> IssueSeverity { + IssueSeverity::Hint + } + + fn category(&self) -> IssueCategory { + IssueCategory::Accessibility + } + + fn check_script(&self, analysis: &ScriptAnalysis) -> Vec { + let mut issues = Vec::new(); + + if let Some(Section::Events(events)) = analysis + .script() + .sections() + .iter() + .find(|s| matches!(s, Section::Events(_))) + { + for event in events { + self.check_event_duration(&mut issues, event); + self.check_reading_speed(&mut issues, event); + self.check_text_length(&mut issues, event); + } + } + + issues + } +} + +impl AccessibilityRule { + /// Check for very short display durations that may be hard to read + fn check_event_duration(&self, issues: &mut Vec, event: &crate::parser::Event) { + if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) { + if end >= start { + let duration_ms = end - start; + if duration_ms < 500 { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Accessibility, + self.id(), + format!("Very short event duration: {duration_ms}ms"), + ) + .with_description("Short durations may be difficult to read".to_string()) + .with_suggested_fix( + "Consider extending duration to at least 500ms for readability".to_string(), + ); + + issues.push(issue); + } + } + } + } + + /// Check reading speed based on text length and duration + fn check_reading_speed(&self, issues: &mut Vec, event: &crate::parser::Event) { + if let (Ok(start), Ok(end)) = (parse_ass_time(event.start), parse_ass_time(event.end)) { + if end >= start { + let duration_centiseconds = end - start; + + if let Ok(analysis) = TextAnalysis::analyze(event.text) { + let clean_text_length = analysis.char_count(); + + if clean_text_length > 0 && duration_centiseconds > 0 { + // Convert centiseconds to seconds: 1 second = 100 centiseconds + let duration_seconds = f64::from(duration_centiseconds) / 100.0; + let safe_length = u32::try_from(clean_text_length) + .unwrap_or(10_000) + .min(10_000); + let chars_per_second = f64::from(safe_length) / duration_seconds; + + if chars_per_second > 20.0 { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Accessibility, + self.id(), + format!( + "Fast reading speed: {chars_per_second:.1} characters/second" + ), + ) + .with_description( + "Fast reading speeds may be difficult for some users".to_string(), + ) + .with_suggested_fix( + "Consider extending duration or reducing text length".to_string(), + ); + + issues.push(issue); + } + } + } + } + } + } + + /// Check for excessively long text that may be overwhelming + fn check_text_length(&self, issues: &mut Vec, event: &crate::parser::Event) { + if let Ok(analysis) = TextAnalysis::analyze(event.text) { + let clean_text_length = analysis.char_count(); + + if clean_text_length > 200 { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Accessibility, + self.id(), + format!("Very long text: {clean_text_length} characters"), + ) + .with_description("Long text blocks may be overwhelming for some users".to_string()) + .with_suggested_fix("Consider splitting into multiple shorter events".to_string()); + + issues.push(issue); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rule_metadata_correct() { + let rule = AccessibilityRule; + assert_eq!(rule.id(), "accessibility"); + assert_eq!(rule.name(), "Accessibility"); + assert_eq!(rule.description(), "Detects potential accessibility issues"); + assert_eq!(rule.default_severity(), IssueSeverity::Hint); + assert_eq!(rule.category(), IssueCategory::Accessibility); + } + + #[test] + fn empty_script_no_issues() { + let script_text = "[Script Info]\nTitle: Test"; + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + + let rule = AccessibilityRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn normal_duration_no_issues() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Normal duration text"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = AccessibilityRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn short_duration_detected() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:00.30,Default,,0,0,0,,Too fast!"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let rule = AccessibilityRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(!issues.is_empty()); + assert!(issues + .iter() + .any(|issue| issue.message().contains("short event duration"))); + } + + #[test] + fn fast_reading_speed_detected() { + let long_text = "This is a very long text that would require fast reading speed to comprehend in the given short duration which may be difficult for some users"; + let script_text = format!( + r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0,0,0,,{long_text}" + ); + + let script = crate::parser::Script::parse(&script_text).unwrap(); + let rule = AccessibilityRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(issues + .iter() + .any(|issue| issue.message().contains("reading speed"))); + } + + #[test] + fn text_analysis_excludes_tags() { + use crate::analysis::events::text_analysis::TextAnalysis; + + let analysis1 = TextAnalysis::analyze("Hello world").unwrap(); + assert_eq!(analysis1.char_count(), 11); + + // "Hello {\i1}world{\i0}" after removing tags becomes "Hello world" (11 chars) + let analysis2 = TextAnalysis::analyze("Hello {\\i1}world{\\i0}").unwrap(); + assert_eq!(analysis2.char_count(), 11); + + // "{\b1}Bold{\b0} text" after removing tags becomes "Bold text" (9 chars) + let analysis3 = TextAnalysis::analyze("{\\b1}Bold{\\b0} text").unwrap(); + assert_eq!(analysis3.char_count(), 9); + + let analysis4 = TextAnalysis::analyze("").unwrap(); + assert_eq!(analysis4.char_count(), 0); + } + + #[test] + fn long_text_detected() { + let long_text = "a".repeat(250); + let script_text = format!( + r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:10.00,Default,,0,0,0,,{long_text}" + ); + + let script = crate::parser::Script::parse(&script_text).unwrap(); + let rule = AccessibilityRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(issues + .iter() + .any(|issue| issue.message().contains("Very long text"))); + } + + #[test] + fn no_events_section_no_issues() { + let script_text = r"[Script Info] +Title: Test + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let rule = AccessibilityRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } +} diff --git a/crates/ass-core/analysis/linting/rules/encoding.rs b/crates/ass-core/analysis/linting/rules/encoding.rs new file mode 100644 index 0000000..a2dd87b --- /dev/null +++ b/crates/ass-core/analysis/linting/rules/encoding.rs @@ -0,0 +1,336 @@ +//! Encoding issue detection rule for ASS script linting. +//! +//! Detects potential encoding or character issues in subtitle scripts +//! that could cause display problems or compatibility issues. + +use crate::{ + analysis::{ + linting::{IssueCategory, IssueSeverity, LintIssue, LintRule}, + ScriptAnalysis, + }, + parser::Section, +}; +use alloc::{format, string::ToString, vec::Vec}; + +/// Rule for detecting encoding and character issues in subtitle scripts +/// +/// Analyzes scripts for problematic characters that may cause encoding +/// issues, display problems, or compatibility issues across different +/// subtitle renderers and media players. +/// +/// # Encoding Checks +/// +/// - Non-printable characters: Detects control characters that shouldn't appear in text +/// - Invalid UTF-8 sequences: Identifies corrupted character data +/// - Suspicious character patterns: Warns about potentially problematic sequences +/// +/// # Performance +/// +/// - Time complexity: O(n * m) for n events and m characters per event +/// - Memory: O(1) additional space +/// - Target: <2ms for typical scripts with 1000 events +/// +/// # Example +/// +/// ```rust +/// use ass_core::analysis::linting::rules::encoding::EncodingRule; +/// use ass_core::analysis::linting::LintRule; +/// use ass_core::{Script, ScriptAnalysis}; +/// +/// let script_text = format!("[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text with{}invalid character", '\x00'); +/// let script = Script::parse(&script_text)?; +/// +/// let rule = EncodingRule; +/// let analysis = ScriptAnalysis::analyze(&script).unwrap(); +/// let issues = rule.check_script(&analysis); +/// assert!(!issues.is_empty()); // Should detect the control character +/// # Ok::<(), Box>(()) +/// ``` +pub struct EncodingRule; + +impl LintRule for EncodingRule { + fn id(&self) -> &'static str { + "encoding" + } + + fn name(&self) -> &'static str { + "Encoding" + } + + fn description(&self) -> &'static str { + "Detects potential encoding or character issues" + } + + fn default_severity(&self) -> IssueSeverity { + IssueSeverity::Warning + } + + fn category(&self) -> IssueCategory { + IssueCategory::Encoding + } + + fn check_script(&self, analysis: &ScriptAnalysis) -> Vec { + let mut issues = Vec::new(); + + if let Some(Section::Events(events)) = analysis + .script() + .sections() + .iter() + .find(|s| matches!(s, Section::Events(_))) + { + for event in events { + self.check_event_encoding(&mut issues, event); + } + } + + self.check_script_info_encoding(&mut issues, analysis.script()); + + issues + } +} + +impl EncodingRule { + /// Check encoding issues in a single event + fn check_event_encoding(&self, issues: &mut Vec, event: &crate::parser::Event) { + if event + .text + .chars() + .any(|c| c.is_control() && c != '\n' && c != '\r' && c != '\t') + { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Encoding, + self.id(), + "Event contains non-printable control characters".to_string(), + ) + .with_description( + "Control characters may cause display issues in subtitle renderers".to_string(), + ) + .with_suggested_fix( + "Remove or replace control characters with appropriate text".to_string(), + ); + issues.push(issue); + } + + if event.text.contains('\u{FFFD}') { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Encoding, + self.id(), + "Event contains Unicode replacement character (�)".to_string(), + ) + .with_description( + "Replacement characters indicate corrupted or invalid encoding".to_string(), + ) + .with_suggested_fix("Check source file encoding and re-import".to_string()); + issues.push(issue); + } + + let char_count = event.text.chars().count(); + let byte_count = event.text.len(); + + // Check for heavy multi-byte character usage + if char_count > 0 && byte_count > char_count * 3 { + let issue = LintIssue::new( + IssueSeverity::Hint, + IssueCategory::Encoding, + self.id(), + "Event contains many multi-byte characters".to_string(), + ) + .with_description( + "Heavy use of multi-byte characters may impact performance".to_string(), + ); + issues.push(issue); + } + } + + /// Check encoding issues in script info section + fn check_script_info_encoding( + &self, + issues: &mut Vec, + script: &crate::parser::Script, + ) { + if let Some(Section::ScriptInfo(info)) = script + .sections() + .iter() + .find(|s| matches!(s, Section::ScriptInfo(_))) + { + for (key, value) in &info.fields { + if value + .chars() + .any(|c| c.is_control() && c != '\n' && c != '\r') + { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Encoding, + self.id(), + format!("Script info field '{key}' contains control characters"), + ); + issues.push(issue); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rule_metadata_correct() { + let rule = EncodingRule; + assert_eq!(rule.id(), "encoding"); + assert_eq!(rule.name(), "Encoding"); + assert_eq!( + rule.description(), + "Detects potential encoding or character issues" + ); + assert_eq!(rule.default_severity(), IssueSeverity::Warning); + assert_eq!(rule.category(), IssueCategory::Encoding); + } + + #[test] + fn empty_script_no_issues() { + let script_text = "[Script Info]\nTitle: Test"; + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + + let rule = EncodingRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn valid_text_no_issues() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Valid text with unicode: ñáéíóú"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = EncodingRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn newlines_allowed() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text with\Nline break"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let rule = EncodingRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn tabs_allowed() { + let script_text = "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text with\ttab"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let rule = EncodingRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn replacement_character_detected() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text with � replacement"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let rule = EncodingRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(!issues.is_empty()); + assert!(issues + .iter() + .any(|issue| issue.message().contains("replacement character"))); + } + + #[test] + fn control_character_in_script_info() { + let script_text = "[Script Info]\nTitle: Test\x00\n\n[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let rule = EncodingRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(!issues.is_empty()); + assert!(issues + .iter() + .any(|issue| issue.message().contains("control characters"))); + } + + #[test] + fn no_events_section_no_issues() { + let script_text = r"[Script Info] +Title: Test + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let rule = EncodingRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn multibyte_characters_hint() { + let heavy_unicode = "🎵🎶🎵🎶".repeat(20); + let script_text = format!( + r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,{heavy_unicode}" + ); + + let script = crate::parser::Script::parse(&script_text).unwrap(); + let rule = EncodingRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(issues + .iter() + .any(|issue| issue.message().contains("multi-byte characters"))); + } + + #[test] + fn control_character_in_event_detected() { + // Test control characters in events (not script info) to cover lines 101-104, 107, 110, 112 + let script_text = "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text with\x00control char"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let rule = EncodingRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(!issues.is_empty()); + assert!(issues + .iter() + .any(|issue| issue.message().contains("non-printable control characters"))); + + // Check that the issue has description and suggested fix + let control_issue = issues + .iter() + .find(|issue| issue.message().contains("non-printable control characters")) + .unwrap(); + assert!(control_issue.description().is_some()); + assert!(control_issue.suggested_fix().is_some()); + } +} diff --git a/crates/ass-core/analysis/linting/rules/invalid_color.rs b/crates/ass-core/analysis/linting/rules/invalid_color.rs new file mode 100644 index 0000000..6e9146c --- /dev/null +++ b/crates/ass-core/analysis/linting/rules/invalid_color.rs @@ -0,0 +1,218 @@ +//! Invalid color format detection rule for ASS script linting. +//! +//! Detects invalid color formats in both style definitions and override tags +//! that would cause rendering errors or unexpected visual results. + +use crate::{ + analysis::{ + events::text_analysis::TextAnalysis, + linting::{IssueCategory, IssueSeverity, LintIssue, LintRule}, + ScriptAnalysis, + }, + parser::Section, + utils::parse_bgr_color, +}; +use alloc::{ + format, + string::{String, ToString}, + vec::Vec, +}; + +/// Rule for detecting invalid color formats in styles and override tags +/// +/// Validates color values in style definitions (primary, secondary, outline, back) +/// and color override tags in event text. Invalid colors cause rendering errors +/// or unexpected visual results in subtitle displays. +/// +/// # Performance +/// +/// - Time complexity: O(n) for n styles + O(m) for m override tags +/// - Memory: O(1) additional space +/// - Target: <2ms for typical scripts with 100 styles and 1000 events +/// +/// # Example +/// +/// ```rust +/// use ass_core::analysis::linting::rules::invalid_color::InvalidColorRule; +/// use ass_core::analysis::linting::LintRule; +/// use ass_core::{Script, ScriptAnalysis}; +/// +/// let script = Script::parse(r#" +/// [V4+ Styles] +/// Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +/// Style: Default,Arial,20,&HINVALID&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +/// "#)?; +/// +/// let analysis = ScriptAnalysis::analyze(&script)?; +/// let rule = InvalidColorRule; +/// let issues = rule.check_script(&analysis); +/// assert!(!issues.is_empty()); // Should detect the invalid color +/// # Ok::<(), Box>(()) +/// ``` +pub struct InvalidColorRule; + +impl LintRule for InvalidColorRule { + fn id(&self) -> &'static str { + "invalid-color" + } + + fn name(&self) -> &'static str { + "Invalid Color" + } + + fn description(&self) -> &'static str { + "Detects invalid color format in styles and override tags" + } + + fn default_severity(&self) -> IssueSeverity { + IssueSeverity::Error + } + + fn category(&self) -> IssueCategory { + IssueCategory::Styling + } + + fn check_script(&self, analysis: &ScriptAnalysis) -> Vec { + let mut issues = Vec::new(); + + if let Some(Section::Styles(styles)) = analysis + .script() + .sections() + .iter() + .find(|s| matches!(s, Section::Styles(_))) + { + for style in styles { + self.check_style_color(&mut issues, "primary", style.primary_colour); + self.check_style_color(&mut issues, "secondary", style.secondary_colour); + self.check_style_color(&mut issues, "outline", style.outline_colour); + self.check_style_color(&mut issues, "back", style.back_colour); + } + } + + if let Some(Section::Events(events)) = analysis + .script() + .sections() + .iter() + .find(|s| matches!(s, Section::Events(_))) + { + for event in events { + let Ok(text_analysis) = TextAnalysis::analyze(event.text) else { + continue; + }; + for tag in text_analysis.override_tags() { + let tag_content = format!("\\{}{}", tag.name(), tag.args()); + self.check_color_override_tags(&mut issues, &tag_content); + } + } + } + + issues + } +} + +impl InvalidColorRule { + /// Check a single style color field for validity + fn check_style_color(&self, issues: &mut Vec, field_name: &str, color_value: &str) { + if parse_bgr_color(color_value).is_err() { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Styling, + self.id(), + format!("Invalid {field_name} color: {color_value}"), + ); + issues.push(issue); + } + } + + /// Check color override tags in an override block + fn check_color_override_tags(&self, issues: &mut Vec, span: &str) { + for tag_part in span.split('\\').skip(1) { + if tag_part.is_empty() { + continue; + } + + if let Some((tag_name, color_value)) = Self::parse_color_tag(tag_part) { + if parse_bgr_color(&format!("&H{color_value}&")).is_err() { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Styling, + self.id(), + format!("Invalid color override tag: \\{tag_name}&H{color_value}&"), + ); + issues.push(issue); + } + } + } + } + + /// Parse a color tag and return (`tag_name`, `color_value`) if valid + fn parse_color_tag(tag_part: &str) -> Option<(String, String)> { + if tag_part.starts_with("c&H") || tag_part.starts_with("c&h") { + let color_part = tag_part + .strip_prefix("c&H") + .or_else(|| tag_part.strip_prefix("c&h"))?; + let color_end = color_part.find('&')?; + let color_value = &color_part[..color_end]; + return Some(("c".to_string(), color_value.to_string())); + } + + if tag_part.len() >= 3 { + let first_char = &tag_part[..1]; + if ["1", "2", "3", "4"].contains(&first_char) && tag_part[1..].starts_with("c&H") { + let color_part = tag_part[1..].strip_prefix("c&H")?; + let color_end = color_part.find('&')?; + let color_value = &color_part[..color_end]; + return Some((format!("{first_char}c"), color_value.to_string())); + } + } + + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(not(feature = "std"))] + use alloc::string::ToString; + + #[test] + fn rule_metadata_correct() { + let rule = InvalidColorRule; + assert_eq!(rule.id(), "invalid-color"); + assert_eq!(rule.name(), "Invalid Color"); + assert_eq!( + rule.description(), + "Detects invalid color format in styles and override tags" + ); + assert_eq!(rule.default_severity(), IssueSeverity::Error); + assert_eq!(rule.category(), IssueCategory::Styling); + } + + #[test] + fn empty_script_no_issues() { + let script_text = "[Script Info]\nTitle: Test"; + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + + let rule = InvalidColorRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn parse_color_tag_formats() { + assert_eq!( + InvalidColorRule::parse_color_tag("c&H00FF00&"), + Some(("c".to_string(), "00FF00".to_string())) + ); + + assert_eq!( + InvalidColorRule::parse_color_tag("1c&H00FF00&"), + Some(("1c".to_string(), "00FF00".to_string())) + ); + + assert!(InvalidColorRule::parse_color_tag("invalid").is_none()); + } +} diff --git a/crates/ass-core/analysis/linting/rules/invalid_tag.rs b/crates/ass-core/analysis/linting/rules/invalid_tag.rs new file mode 100644 index 0000000..9a863ba --- /dev/null +++ b/crates/ass-core/analysis/linting/rules/invalid_tag.rs @@ -0,0 +1,204 @@ +//! Invalid tag detection rule for ASS script linting. +//! +//! Detects invalid or malformed override tags in event text that would +//! cause parsing errors or unexpected rendering behavior. + +use crate::{ + analysis::{ + events::{text_analysis::TextAnalysis, DiagnosticKind}, + linting::{IssueCategory, IssueSeverity, LintIssue, LintRule}, + ScriptAnalysis, + }, + parser::Section, +}; +use alloc::{format, string::ToString, vec::Vec}; + +/// Rule for detecting invalid or malformed override tags in event text +/// +/// Override tags control text formatting and positioning within subtitle events. +/// Malformed tags can cause parsing errors, rendering glitches, or unexpected +/// visual behavior in subtitle displays. +/// +/// # Performance +/// +/// - Time complexity: O(n * m) for n events and m tokens per event +/// - Memory: O(1) additional space +/// - Target: <2ms for typical scripts with 1000 events +/// +/// # Example +/// +/// ```rust +/// use ass_core::analysis::linting::rules::invalid_tag::InvalidTagRule; +/// use ass_core::analysis::linting::LintRule; +/// use ass_core::{Script, ScriptAnalysis}; +/// +/// let script = Script::parse(r#" +/// [V4+ Styles] +/// Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +/// Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +/// +/// [Events] +/// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +/// Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text with {\invalid_tag} +/// "#)?; +/// +/// let analysis = ScriptAnalysis::analyze(&script)?; +/// let rule = InvalidTagRule; +/// let issues = rule.check_script(&analysis); +/// // Rule should execute successfully (implementation may vary) +/// assert!(issues.is_empty()); // Rule runs without panic +/// # Ok::<(), Box>(()) +/// ``` +pub struct InvalidTagRule; + +impl LintRule for InvalidTagRule { + fn id(&self) -> &'static str { + "invalid-tag" + } + + fn name(&self) -> &'static str { + "Invalid Tag" + } + + fn description(&self) -> &'static str { + "Detects invalid or malformed override tags in event text" + } + + fn default_severity(&self) -> IssueSeverity { + IssueSeverity::Warning + } + + fn category(&self) -> IssueCategory { + IssueCategory::Content + } + + fn check_script(&self, analysis: &ScriptAnalysis) -> Vec { + let mut issues = Vec::new(); + + if let Some(Section::Events(events)) = analysis + .script() + .sections() + .iter() + .find(|s| matches!(s, Section::Events(_))) + { + for event in events { + let Ok(text_analysis) = TextAnalysis::analyze(event.text) else { + continue; + }; + + // Use diagnostics collected during text analysis + for diagnostic in text_analysis.diagnostics() { + let message = match diagnostic.kind { + DiagnosticKind::EmptyOverride => "Empty override tag found".to_string(), + DiagnosticKind::MalformedTag => "Malformed override tag syntax".to_string(), + DiagnosticKind::UnknownTag(ref tag_name) => { + format!("Unknown tag: {tag_name}") + } + }; + + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Content, + self.id(), + message, + ); + issues.push(issue); + } + } + } + + issues + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rule_metadata_correct() { + let rule = InvalidTagRule; + assert_eq!(rule.id(), "invalid-tag"); + assert_eq!(rule.name(), "Invalid Tag"); + assert_eq!( + rule.description(), + "Detects invalid or malformed override tags in event text" + ); + assert_eq!(rule.default_severity(), IssueSeverity::Warning); + assert_eq!(rule.category(), IssueCategory::Content); + } + + #[test] + fn empty_script_no_issues() { + let script_text = "[Script Info]\nTitle: Test"; + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + + let rule = InvalidTagRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn valid_tags_no_issues() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text with {\i1}valid{\i0} tags"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let rule = InvalidTagRule; + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn no_events_section_no_issues() { + let script_text = r"[Script Info] +Title: Test + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = InvalidTagRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn plain_text_no_issues() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Plain text without any tags"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = InvalidTagRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn empty_tag_after_valid_tag_detected() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Text with {\b1\} override"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = InvalidTagRule; + let issues = rule.check_script(&analysis); + + assert!(!issues.is_empty()); + assert!(issues + .iter() + .any(|issue| issue.message().contains("Empty override tag"))); + } +} diff --git a/crates/ass-core/analysis/linting/rules/missing_style.rs b/crates/ass-core/analysis/linting/rules/missing_style.rs new file mode 100644 index 0000000..31b6ef3 --- /dev/null +++ b/crates/ass-core/analysis/linting/rules/missing_style.rs @@ -0,0 +1,211 @@ +//! Missing style reference detection rule for ASS script linting. +//! +//! Detects events that reference undefined styles, which would cause +//! rendering errors or fallback to default styling behavior. + +use crate::{ + analysis::{ + linting::{IssueCategory, IssueSeverity, LintIssue, LintRule}, + ScriptAnalysis, + }, + parser::Section, +}; +use alloc::{format, vec::Vec}; + +/// Rule for detecting events that reference undefined styles +/// +/// Events must reference valid style names defined in the [V4+ Styles] section. +/// Missing style references cause renderers to fall back to default styling, +/// which may not match the intended visual appearance. +/// +/// # Performance +/// +/// - Time complexity: O(n + m) for n styles and m events +/// - Memory: O(n) for style name collection +/// - Target: <1ms for typical scripts with 100 styles and 1000 events +/// +/// # Example +/// +/// ```rust +/// use ass_core::analysis::linting::rules::missing_style::MissingStyleRule; +/// use ass_core::analysis::linting::LintRule; +/// use ass_core::{Script, ScriptAnalysis}; +/// +/// let script = Script::parse(r#" +/// [V4+ Styles] +/// Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +/// Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +/// +/// [Events] +/// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +/// Dialogue: 0,0:00:00.00,0:00:05.00,Undefined,,0,0,0,,Text with undefined style +/// "#)?; +/// +/// let analysis = ScriptAnalysis::analyze(&script)?; +/// let rule = MissingStyleRule; +/// let issues = rule.check_script(&analysis); +/// assert!(!issues.is_empty()); // Should detect the missing style reference +/// # Ok::<(), Box>(()) +/// ``` +pub struct MissingStyleRule; + +impl LintRule for MissingStyleRule { + fn id(&self) -> &'static str { + "missing-style" + } + + fn name(&self) -> &'static str { + "Missing Style" + } + + fn description(&self) -> &'static str { + "Detects events referencing non-existent styles" + } + + fn default_severity(&self) -> IssueSeverity { + IssueSeverity::Error + } + + fn category(&self) -> IssueCategory { + IssueCategory::Styling + } + + fn check_script(&self, analysis: &ScriptAnalysis) -> Vec { + let mut issues = Vec::new(); + + let style_names: Vec<&str> = analysis + .resolved_styles() + .iter() + .map(|style| style.name) + .collect(); + + if let Some(Section::Events(events)) = analysis + .script() + .sections() + .iter() + .find(|s| matches!(s, Section::Events(_))) + { + for event in events { + if !style_names.contains(&event.style) { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Styling, + self.id(), + format!("Event references undefined style: {}", event.style), + ) + .with_suggested_fix(format!( + "Define style '{}' or use an existing style", + event.style + )); + + issues.push(issue); + } + } + } + + issues + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rule_metadata_correct() { + let rule = MissingStyleRule; + assert_eq!(rule.id(), "missing-style"); + assert_eq!(rule.name(), "Missing Style"); + assert_eq!( + rule.description(), + "Detects events referencing non-existent styles" + ); + assert_eq!(rule.default_severity(), IssueSeverity::Error); + assert_eq!(rule.category(), IssueCategory::Styling); + } + + #[test] + fn empty_script_no_issues() { + let script_text = "[Script Info]\nTitle: Test"; + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + + let rule = MissingStyleRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn valid_style_reference_no_issues() { + let script_text = r"[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Valid event"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = MissingStyleRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn missing_style_reference_detected() { + let script_text = r"[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Undefined,,0,0,0,,Invalid event"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = MissingStyleRule; + let issues = rule.check_script(&analysis); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].severity(), IssueSeverity::Error); + assert_eq!(issues[0].category(), IssueCategory::Styling); + assert!(issues[0].message().contains("Undefined")); + } + + #[test] + fn multiple_missing_styles() { + let script_text = r"[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Missing1,,0,0,0,,First invalid +Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,Valid event +Dialogue: 0,0:00:10.00,0:00:15.00,Missing2,,0,0,0,,Second invalid"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = MissingStyleRule; + let issues = rule.check_script(&analysis); + + assert_eq!(issues.len(), 2); + } + + #[test] + fn no_styles_section_all_invalid() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Should be invalid"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = MissingStyleRule; + let issues = rule.check_script(&analysis); + + assert_eq!(issues.len(), 1); + } +} diff --git a/crates/ass-core/analysis/linting/rules/mod.rs b/crates/ass-core/analysis/linting/rules/mod.rs new file mode 100644 index 0000000..e92e2c0 --- /dev/null +++ b/crates/ass-core/analysis/linting/rules/mod.rs @@ -0,0 +1,227 @@ +//! Built-in linting rules for ASS script validation. +//! +//! This module contains implementations of all built-in linting rules +//! that check for common issues in ASS subtitle scripts. Each rule +//! is implemented in a separate module for better maintainability. +//! +//! # Rule Categories +//! +//! - **Timing Rules**: Check for overlaps, negative durations, and timing issues +//! - **Style Rules**: Validate style references and color formats +//! - **Content Rules**: Check tag validity and text formatting +//! - **Performance Rules**: Detect performance-impacting patterns +//! - **Accessibility Rules**: Ensure compatibility and readability +//! +//! # Example +//! +//! ```rust +//! use ass_core::analysis::linting::rules::BuiltinRules; +//! use ass_core::analysis::linting::LintRule; +//! use ass_core::{Script, ScriptAnalysis}; +//! +//! let script = Script::parse("...")?; +//! let rules = BuiltinRules::all_rules(); +//! +//! for rule in rules { +//! let analysis = ScriptAnalysis::analyze(&script).unwrap(); +//! let issues = rule.check_script(&analysis); +//! for issue in issues { +//! println!("{}: {}", rule.name(), issue.message()); +//! } +//! } +//! # Ok::<(), Box>(()) +//! ``` + +use super::LintRule; +use alloc::{boxed::Box, vec, vec::Vec}; + +pub mod accessibility; +pub mod encoding; +pub mod invalid_color; +pub mod invalid_tag; +pub mod missing_style; +pub mod negative_duration; +pub mod performance; +pub mod timing_overlap; + +pub use accessibility::AccessibilityRule; +pub use encoding::EncodingRule; +pub use invalid_color::InvalidColorRule; +pub use invalid_tag::InvalidTagRule; +pub use missing_style::MissingStyleRule; +pub use negative_duration::NegativeDurationRule; +pub use performance::PerformanceRule; +pub use timing_overlap::TimingOverlapRule; + +/// Built-in lint rules registry +/// +/// Provides access to all built-in rules that check for common issues +/// in ASS subtitle scripts. Rules are organized by category and can be +/// used individually or as a complete set. +/// +/// # Performance +/// +/// All rules are designed for efficient execution with minimal memory +/// overhead. Most rules have O(n) or O(n log n) time complexity. +/// +/// # Rule List +/// +/// - `TimingOverlapRule`: Detects overlapping dialogue events +/// - `NegativeDurationRule`: Finds events with invalid durations +/// - `InvalidColorRule`: Validates color formats in styles and tags +/// - `MissingStyleRule`: Checks for undefined style references +/// - `InvalidTagRule`: Detects malformed override tags +/// - `PerformanceRule`: Identifies performance-impacting patterns +/// - `EncodingRule`: Validates text encoding and character usage +/// - `AccessibilityRule`: Ensures readability and compatibility +pub struct BuiltinRules; + +impl BuiltinRules { + /// Get all built-in linting rules + /// + /// Returns a vector of all available built-in rules ready for use. + /// Rules are returned in their default configuration with standard + /// severity levels and categories. + /// + /// # Example + /// + /// ```rust + /// use ass_core::analysis::linting::rules::BuiltinRules; + /// + /// let rules = BuiltinRules::all_rules(); + /// assert_eq!(rules.len(), 8); // All built-in rules + /// ``` + #[must_use] + pub fn all_rules() -> Vec> { + vec![ + Box::new(TimingOverlapRule), + Box::new(NegativeDurationRule), + Box::new(InvalidColorRule), + Box::new(MissingStyleRule), + Box::new(InvalidTagRule), + Box::new(PerformanceRule), + Box::new(EncodingRule), + Box::new(AccessibilityRule), + ] + } + + /// Get rules by category + /// + /// Returns only rules that check issues in the specified category. + /// Useful for focused linting or when only certain types of issues + /// need to be checked. + /// + /// # Arguments + /// + /// * `category` - The issue category to filter by + /// + /// # Example + /// + /// ```rust + /// use ass_core::analysis::linting::{IssueCategory, rules::BuiltinRules}; + /// + /// let timing_rules = BuiltinRules::rules_for_category(IssueCategory::Timing); + /// // Returns timing-related rules only + /// ``` + #[must_use] + pub fn rules_for_category(category: super::IssueCategory) -> Vec> { + Self::all_rules() + .into_iter() + .filter(|rule| rule.category() == category) + .collect() + } + + /// Get rule by ID + /// + /// Returns the rule with the specified ID, or None if no such rule exists. + /// Rule IDs are unique identifiers used for configuration and reporting. + /// + /// # Arguments + /// + /// * `id` - The rule ID to search for + /// + /// # Example + /// + /// ```rust + /// use ass_core::analysis::linting::rules::BuiltinRules; + /// + /// let rule = BuiltinRules::rule_by_id("timing-overlap"); + /// assert!(rule.is_some()); + /// assert_eq!(rule.unwrap().id(), "timing-overlap"); + /// ``` + #[must_use] + pub fn rule_by_id(id: &str) -> Option> { + Self::all_rules().into_iter().find(|rule| rule.id() == id) + } + + /// Get all rule IDs + /// + /// Returns a vector of all available rule IDs for configuration + /// and reporting purposes. + /// + /// # Example + /// + /// ```rust + /// use ass_core::analysis::linting::rules::BuiltinRules; + /// + /// let ids = BuiltinRules::all_rule_ids(); + /// assert!(ids.contains(&"timing-overlap")); + /// assert!(ids.contains(&"negative-duration")); + /// ``` + #[must_use] + pub fn all_rule_ids() -> Vec<&'static str> { + Self::all_rules().iter().map(|rule| rule.id()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_rules_count_correct() { + let rules = BuiltinRules::all_rules(); + assert_eq!(rules.len(), 8); + } + + #[test] + fn all_rules_have_unique_ids() { + let rules = BuiltinRules::all_rules(); + let mut ids = Vec::new(); + + for rule in rules { + let id = rule.id(); + assert!(!ids.contains(&id), "Duplicate rule ID: {id}"); + ids.push(id); + } + } + + #[test] + fn rule_by_id_works() { + let rule = BuiltinRules::rule_by_id("timing-overlap"); + assert!(rule.is_some()); + assert_eq!(rule.unwrap().id(), "timing-overlap"); + + let missing = BuiltinRules::rule_by_id("nonexistent"); + assert!(missing.is_none()); + } + + #[test] + fn all_rule_ids_complete() { + let ids = BuiltinRules::all_rule_ids(); + let expected_ids = [ + "timing-overlap", + "negative-duration", + "invalid-color", + "missing-style", + "invalid-tag", + "performance", + "encoding", + "accessibility", + ]; + + for expected_id in expected_ids { + assert!(ids.contains(&expected_id), "Missing rule ID: {expected_id}"); + } + } +} diff --git a/crates/ass-core/analysis/linting/rules/negative_duration.rs b/crates/ass-core/analysis/linting/rules/negative_duration.rs new file mode 100644 index 0000000..8a7348c --- /dev/null +++ b/crates/ass-core/analysis/linting/rules/negative_duration.rs @@ -0,0 +1,196 @@ +//! Negative duration detection rule for ASS script linting. +//! +//! Detects events with negative or zero duration that would cause +//! rendering issues or indicate timing errors in subtitle scripts. + +use crate::{ + analysis::{ + linting::{IssueCategory, IssueSeverity, LintIssue, LintRule}, + ScriptAnalysis, + }, + parser::Section, + utils::parse_ass_time, +}; +use alloc::{format, string::ToString, vec::Vec}; + +/// Rule for detecting events with negative or zero duration +/// +/// Events with start time >= end time are invalid and will not display +/// properly in most subtitle renderers. This rule catches timing errors +/// that could result from manual editing mistakes or conversion issues. +/// +/// # Performance +/// +/// - Time complexity: O(n) for n events +/// - Memory: O(1) additional space +/// - Target: <0.5ms for 1000 events +/// +/// # Example +/// +/// ```rust +/// use ass_core::analysis::linting::rules::negative_duration::NegativeDurationRule; +/// use ass_core::analysis::linting::LintRule; +/// use ass_core::{Script, ScriptAnalysis}; +/// +/// let script = Script::parse(r#" +/// [Events] +/// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +/// Dialogue: 0,0:00:05.00,0:00:02.00,Default,,0,0,0,,Invalid event +/// "#)?; +/// +/// let analysis = ScriptAnalysis::analyze(&script)?; +/// let rule = NegativeDurationRule; +/// let issues = rule.check_script(&analysis); +/// assert!(!issues.is_empty()); // Should detect the negative duration +/// # Ok::<(), Box>(()) +/// ``` +pub struct NegativeDurationRule; + +impl LintRule for NegativeDurationRule { + fn id(&self) -> &'static str { + "negative-duration" + } + + fn name(&self) -> &'static str { + "Negative Duration" + } + + fn description(&self) -> &'static str { + "Detects events with negative or zero duration" + } + + fn default_severity(&self) -> IssueSeverity { + IssueSeverity::Error + } + + fn category(&self) -> IssueCategory { + IssueCategory::Timing + } + + fn check_script(&self, analysis: &ScriptAnalysis) -> Vec { + let mut issues = Vec::new(); + + if let Some(Section::Events(events)) = analysis + .script() + .sections() + .iter() + .find(|s| matches!(s, Section::Events(_))) + { + for event in events { + if let (Ok(start), Ok(end)) = + (parse_ass_time(event.start), parse_ass_time(event.end)) + { + if start >= end { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Timing, + self.id(), + format!( + "Invalid duration: start {} >= end {}", + event.start, event.end + ), + ) + .with_description( + "Events must have positive duration for proper display".to_string(), + ); + + issues.push(issue); + } + } + } + } + + issues + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rule_metadata_correct() { + let rule = NegativeDurationRule; + assert_eq!(rule.id(), "negative-duration"); + assert_eq!(rule.name(), "Negative Duration"); + assert_eq!( + rule.description(), + "Detects events with negative or zero duration" + ); + assert_eq!(rule.default_severity(), IssueSeverity::Error); + assert_eq!(rule.category(), IssueCategory::Timing); + } + + #[test] + fn empty_script_no_issues() { + let script_text = "[Script Info]\nTitle: Test"; + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + + let rule = NegativeDurationRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn valid_duration_no_issues() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Valid event"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = NegativeDurationRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn negative_duration_detected() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:05.00,0:00:02.00,Default,,0,0,0,,Invalid event"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = NegativeDurationRule; + let issues = rule.check_script(&analysis); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].severity(), IssueSeverity::Error); + assert_eq!(issues[0].category(), IssueCategory::Timing); + } + + #[test] + fn zero_duration_detected() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:05.00,0:00:05.00,Default,,0,0,0,,Zero duration"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = NegativeDurationRule; + let issues = rule.check_script(&analysis); + + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].severity(), IssueSeverity::Error); + } + + #[test] + fn multiple_invalid_durations() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:05.00,0:00:02.00,Default,,0,0,0,,First invalid +Dialogue: 0,0:00:01.00,0:00:06.00,Default,,0,0,0,,Valid event +Dialogue: 0,0:00:10.00,0:00:10.00,Default,,0,0,0,,Second invalid"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = NegativeDurationRule; + let issues = rule.check_script(&analysis); + + assert_eq!(issues.len(), 2); + } +} diff --git a/crates/ass-core/analysis/linting/rules/performance.rs b/crates/ass-core/analysis/linting/rules/performance.rs new file mode 100644 index 0000000..450f2b3 --- /dev/null +++ b/crates/ass-core/analysis/linting/rules/performance.rs @@ -0,0 +1,255 @@ +//! Performance issue detection rule for ASS script linting. +//! +//! Detects potential performance issues in subtitle scripts that could +//! impact rendering speed, memory usage, or playback smoothness. + +use crate::analysis::{ + linting::{IssueCategory, IssueSeverity, LintIssue, LintRule}, + ScriptAnalysis, +}; +use alloc::{format, string::ToString, vec::Vec}; + +/// Rule for detecting potential performance issues in subtitle scripts +/// +/// Analyzes scripts for patterns that may negatively impact rendering +/// performance, memory usage, or playback smoothness. Helps identify +/// optimization opportunities in large or complex subtitle files. +/// +/// # Performance Checks +/// +/// - Event count: Warns when script has excessive number of events +/// - Complex text: Detects events with very long text content +/// - Frequent styling: Identifies excessive use of override tags +/// +/// # Performance +/// +/// - Time complexity: O(n) for n events +/// - Memory: O(1) additional space +/// - Target: <1ms for typical scripts with 1000+ events +/// +/// # Example +/// +/// ```rust +/// use ass_core::analysis::linting::rules::performance::PerformanceRule; +/// use ass_core::analysis::linting::LintRule; +/// use ass_core::{Script, ScriptAnalysis}; +/// +/// let script = Script::parse(r#" +/// [Events] +/// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +/// "#)?; // Script with many events would trigger warnings +/// +/// let analysis = ScriptAnalysis::analyze(&script)?; +/// let rule = PerformanceRule; +/// let issues = rule.check_script(&analysis); +/// # Ok::<(), Box>(()) +/// ``` +pub struct PerformanceRule; + +impl LintRule for PerformanceRule { + fn id(&self) -> &'static str { + "performance" + } + + fn name(&self) -> &'static str { + "Performance" + } + + fn description(&self) -> &'static str { + "Detects potential performance issues in the script" + } + + fn default_severity(&self) -> IssueSeverity { + IssueSeverity::Hint + } + + fn category(&self) -> IssueCategory { + IssueCategory::Performance + } + + fn check_script(&self, analysis: &ScriptAnalysis) -> Vec { + let mut issues = Vec::new(); + + let dialogue_info = analysis.dialogue_info(); + self.check_event_count(&mut issues, dialogue_info.len()); + self.check_complex_events(&mut issues, dialogue_info); + + issues + } +} + +impl PerformanceRule { + /// Check for excessive number of events + fn check_event_count(&self, issues: &mut Vec, event_count: usize) { + if event_count > 1000 { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Performance, + self.id(), + format!("Script has {event_count} events, consider optimization"), + ) + .with_description("Large number of events may impact rendering performance".to_string()) + .with_suggested_fix( + "Consider splitting into multiple files or optimizing timing".to_string(), + ); + + issues.push(issue); + } + } + + /// Check for performance-impacting patterns in individual events + fn check_complex_events( + &self, + issues: &mut Vec, + dialogue_info: &[crate::analysis::DialogueInfo], + ) { + for info in dialogue_info { + let text_analysis = info.text_analysis(); + let text_length = text_analysis.char_count(); + + if text_length > 500 { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Performance, + self.id(), + format!("Event has very long text ({text_length} characters)"), + ) + .with_description( + "Very long event text may impact rendering performance".to_string(), + ) + .with_suggested_fix( + "Consider splitting long text into multiple events".to_string(), + ); + + issues.push(issue); + } + + let override_count = text_analysis.override_tags().len(); + if override_count > 20 { + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Performance, + self.id(), + format!("Event has many override tags ({override_count} blocks)"), + ) + .with_description( + "Excessive override tags may impact rendering performance".to_string(), + ) + .with_suggested_fix( + "Consider using styles instead of many override tags".to_string(), + ); + + issues.push(issue); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(not(feature = "std"))] + use alloc::string::String; + + #[test] + fn rule_metadata_correct() { + let rule = PerformanceRule; + assert_eq!(rule.id(), "performance"); + assert_eq!(rule.name(), "Performance"); + assert_eq!( + rule.description(), + "Detects potential performance issues in the script" + ); + assert_eq!(rule.default_severity(), IssueSeverity::Hint); + assert_eq!(rule.category(), IssueCategory::Performance); + } + + #[test] + fn empty_script_no_issues() { + let script_text = "[Script Info]\nTitle: Test"; + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + + let rule = PerformanceRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn small_script_no_issues() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Short text +Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,Another short text"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = PerformanceRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn no_events_section_no_issues() { + let script_text = r"[Script Info] +Title: Test + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF&,&H000000FF&,&H00000000&,&H00000000&,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = PerformanceRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn long_text_event_detected() { + let long_text = "a".repeat(600); + let script_text = format!( + r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,{long_text}" + ); + + let script = crate::parser::Script::parse(&script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = PerformanceRule; + let issues = rule.check_script(&analysis); + + assert!(!issues.is_empty()); + assert!(issues + .iter() + .any(|issue| issue.message().contains("long text"))); + } + + #[test] + fn many_override_tags_detected() { + let mut text_with_tags = String::new(); + for i in 0..25 { + use core::fmt::Write; + write!(text_with_tags, "{{\\i{}}}text", i % 2).unwrap(); + } + + let script_text = format!( + r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,{text_with_tags}" + ); + + let script = crate::parser::Script::parse(&script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = PerformanceRule; + let issues = rule.check_script(&analysis); + + assert!(!issues.is_empty()); + assert!(issues + .iter() + .any(|issue| issue.message().contains("override tags"))); + } +} diff --git a/crates/ass-core/analysis/linting/rules/timing_overlap.rs b/crates/ass-core/analysis/linting/rules/timing_overlap.rs new file mode 100644 index 0000000..1eccc1d --- /dev/null +++ b/crates/ass-core/analysis/linting/rules/timing_overlap.rs @@ -0,0 +1,158 @@ +//! Timing overlap detection rule for ASS script linting. +//! +//! Detects overlapping dialogue events that may cause rendering conflicts +//! using efficient O(n log n) sweep-line algorithm. + +use crate::{ + analysis::{ + events::find_overlapping_events, + linting::{IssueCategory, IssueSeverity, LintIssue, LintRule}, + ScriptAnalysis, + }, + parser::Section, +}; +use alloc::{format, string::ToString, vec::Vec}; + +/// Rule for detecting timing overlaps between dialogue events +/// +/// Uses sweep-line algorithm for efficient O(n log n) overlap detection. +/// Overlapping events can cause rendering conflicts where multiple subtitles +/// appear simultaneously, potentially causing readability issues. +/// +/// # Performance +/// +/// - Time complexity: O(n log n) via sweep-line algorithm +/// - Memory: O(n) for temporary data structures +/// - Target: <1ms for 1000 events on typical hardware +/// +/// # Example +/// +/// ```rust +/// use ass_core::analysis::linting::rules::timing_overlap::TimingOverlapRule; +/// use ass_core::analysis::linting::LintRule; +/// use ass_core::{Script, ScriptAnalysis}; +/// +/// let script = Script::parse(r#" +/// [Events] +/// Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +/// Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,First event +/// Dialogue: 0,0:00:03.00,0:00:08.00,Default,,0,0,0,,Overlapping event +/// "#)?; +/// +/// let analysis = ScriptAnalysis::analyze(&script)?; +/// let rule = TimingOverlapRule; +/// let issues = rule.check_script(&analysis); +/// assert!(!issues.is_empty()); // Should detect the overlap +/// # Ok::<(), Box>(()) +/// ``` +pub struct TimingOverlapRule; + +impl LintRule for TimingOverlapRule { + fn id(&self) -> &'static str { + "timing-overlap" + } + + fn name(&self) -> &'static str { + "Timing Overlap" + } + + fn description(&self) -> &'static str { + "Detects overlapping dialogue events that may cause rendering conflicts" + } + + fn default_severity(&self) -> IssueSeverity { + IssueSeverity::Warning + } + + fn category(&self) -> IssueCategory { + IssueCategory::Timing + } + + fn check_script(&self, analysis: &ScriptAnalysis) -> Vec { + let mut issues = Vec::new(); + + if let Some(Section::Events(events)) = analysis + .script() + .sections() + .iter() + .find(|s| matches!(s, Section::Events(_))) + { + if let Ok(overlaps) = find_overlapping_events(events) { + for (i, j) in overlaps { + let event1 = &events[i]; + let event2 = &events[j]; + + let issue = LintIssue::new( + self.default_severity(), + IssueCategory::Timing, + self.id(), + format!( + "Event overlaps: {} to {} overlaps with {} to {}", + event1.start, event1.end, event2.start, event2.end + ), + ) + .with_description( + "Overlapping events may cause rendering conflicts".to_string(), + ); + + issues.push(issue); + } + } else { + let issue = LintIssue::new( + IssueSeverity::Warning, + IssueCategory::Timing, + self.id(), + "Could not analyze event overlaps due to timing parse errors".to_string(), + ); + issues.push(issue); + } + } + + issues + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rule_metadata_correct() { + let rule = TimingOverlapRule; + assert_eq!(rule.id(), "timing-overlap"); + assert_eq!(rule.name(), "Timing Overlap"); + assert_eq!( + rule.description(), + "Detects overlapping dialogue events that may cause rendering conflicts" + ); + assert_eq!(rule.default_severity(), IssueSeverity::Warning); + assert_eq!(rule.category(), IssueCategory::Timing); + } + + #[test] + fn empty_script_no_issues() { + let script_text = "[Script Info]\nTitle: Test"; + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + + let rule = TimingOverlapRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } + + #[test] + fn non_overlapping_events_no_issues() { + let script_text = r"[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,First event +Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,Second event"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + let rule = TimingOverlapRule; + let issues = rule.check_script(&analysis); + + assert!(issues.is_empty()); + } +} diff --git a/crates/ass-core/analysis/mod.rs b/crates/ass-core/analysis/mod.rs new file mode 100644 index 0000000..cc90395 --- /dev/null +++ b/crates/ass-core/analysis/mod.rs @@ -0,0 +1,505 @@ +//! Script analysis and linting for ASS subtitle scripts +//! +//! Provides comprehensive analysis capabilities including style resolution, +//! linting for common issues, and performance optimization suggestions. +//! Designed for editor integration and script validation. +//! +//! # Features +//! +//! - Style resolution: Compute effective styles from base + overrides +//! - Linting rules: Detect common problems and spec violations +//! - Performance analysis: Identify rendering bottlenecks +//! - Unicode handling: Bidirectional text and linebreak analysis +//! - Timing validation: Overlap detection and duration checks +//! +//! # Performance +//! +//! - Target: <2ms analysis for typical scripts +//! - Memory: Lazy evaluation to avoid allocation spikes +//! - Thread-safe: Immutable analysis results +//! +//! # Example +//! +//! ```rust +//! use ass_core::{Script, analysis::ScriptAnalysis}; +//! +//! let script_text = r#" +//! [Script Info] +//! Title: Test +//! +//! [V4+ Styles] +//! Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +//! Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +//! +//! [Events\] +//! Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +//! Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello World! +//! "#; +//! +//! let script = Script::parse(script_text)?; +//! let analysis = ScriptAnalysis::analyze(&script)?; +//! +//! // Check for issues +//! for issue in analysis.lint_issues() { +//! println!("Warning: {}", issue.message()); +//! } +//! +//! // Get resolved styles +//! if let Some(style) = analysis.resolve_style("Default") { +//! println!("Font: {}", style.font_name()); +//! } +//! # Ok::<(), Box>(()) +//! ``` + +use crate::{ + parser::{Script, Section}, + Result, +}; + +#[cfg(feature = "plugins")] +use crate::plugin::ExtensionRegistry; +use alloc::vec::Vec; + +bitflags::bitflags! { + /// Script analysis options + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct ScriptAnalysisOptions: u8 { + /// Enable Unicode linebreak analysis (libass 0.17.4+) + const UNICODE_LINEBREAKS = 1 << 0; + /// Enable performance warnings + const PERFORMANCE_HINTS = 1 << 1; + /// Enable strict spec compliance checking + const STRICT_COMPLIANCE = 1 << 2; + /// Enable bidirectional text analysis + const BIDI_ANALYSIS = 1 << 3; + } +} + +pub mod events; +pub mod linting; +pub mod styles; + +pub use events::{ + count_overlapping_dialogue_events, count_overlapping_events, find_overlapping_dialogue_events, + find_overlapping_events, DialogueInfo, +}; +pub use linting::{lint_script, LintConfig, LintIssue, LintRule}; +pub use styles::{ResolvedStyle, StyleAnalyzer}; + +/// Comprehensive analysis of an ASS script +/// +/// Provides linting, style resolution, and performance analysis. +/// Results are cached for efficient repeated access. +#[derive(Debug, Clone)] +pub struct ScriptAnalysis<'a> { + /// Reference to analyzed script + pub script: &'a Script<'a>, + + /// Detected lint issues + lint_issues: Vec, + + /// Resolved styles cache + resolved_styles: Vec>, + + /// Dialogue analysis results + dialogue_info: Vec>, + + /// Analysis configuration + config: AnalysisConfig, + + /// Extension registry for custom tag handlers + #[cfg(feature = "plugins")] + registry: Option<&'a ExtensionRegistry>, +} + +/// Configuration for script analysis +#[derive(Debug, Clone)] +pub struct AnalysisConfig { + /// Analysis options flags + pub options: ScriptAnalysisOptions, + + /// Maximum allowed events for performance warnings + pub max_events_threshold: usize, +} + +impl Default for AnalysisConfig { + fn default() -> Self { + Self { + options: ScriptAnalysisOptions::UNICODE_LINEBREAKS + | ScriptAnalysisOptions::PERFORMANCE_HINTS + | ScriptAnalysisOptions::BIDI_ANALYSIS, + max_events_threshold: 1000, + } + } +} + +impl<'a> ScriptAnalysis<'a> { + /// Analyze script with default configuration + /// + /// Performs comprehensive analysis including linting, style resolution, + /// and event analysis. Results are cached for efficient access. + /// Analyze ASS script for issues, styles, and content + /// + /// # Performance + /// + /// Target <2ms for typical scripts. Uses lazy evaluation for expensive + /// operations like Unicode analysis. + /// + /// # Errors + /// + /// Returns an error if script analysis fails or contains invalid data. + pub fn analyze(script: &'a Script<'a>) -> Result { + #[cfg(feature = "plugins")] + return Self::analyze_with_registry(script, None, AnalysisConfig::default()); + #[cfg(not(feature = "plugins"))] + return Self::analyze_with_config(script, AnalysisConfig::default()); + } + + /// Analyze script with extension registry support + /// + /// Same as [`analyze`](Self::analyze) but allows custom tag handlers via registry. + /// Uses default analysis configuration. + /// + /// # Arguments + /// + /// * `script` - Script to analyze + /// * `registry` - Optional registry for custom tag handlers + /// + /// # Errors + /// + /// Returns an error if script analysis fails or contains invalid data. + #[cfg(feature = "plugins")] + pub fn analyze_with_registry( + script: &'a Script<'a>, + registry: Option<&'a ExtensionRegistry>, + config: AnalysisConfig, + ) -> Result { + Ok(Self::analyze_impl(script, registry, config)) + } + + /// Analyze script with custom configuration + /// + /// Allows fine-tuning analysis behavior for specific use cases. + /// + /// # Errors + /// + /// Returns an error if script analysis fails or contains invalid data. + pub fn analyze_with_config(script: &'a Script<'a>, config: AnalysisConfig) -> Result { + #[cfg(feature = "plugins")] + return Ok(Self::analyze_impl(script, None, config)); + #[cfg(not(feature = "plugins"))] + return Ok(Self::analyze_impl_no_plugins(script, config)); + } + + /// Internal implementation with plugins support + #[cfg(feature = "plugins")] + fn analyze_impl( + script: &'a Script<'a>, + registry: Option<&'a ExtensionRegistry>, + config: AnalysisConfig, + ) -> Self { + let mut analysis = Self { + script, + lint_issues: Vec::new(), + resolved_styles: Vec::new(), + dialogue_info: Vec::new(), + config, + registry, + }; + + analysis.resolve_all_styles(); + analysis.analyze_events(); + analysis.run_linting(); + + analysis + } + + /// Internal implementation without plugins support + #[cfg(not(feature = "plugins"))] + fn analyze_impl_no_plugins(script: &'a Script<'a>, config: AnalysisConfig) -> Self { + let mut analysis = Self { + script, + lint_issues: Vec::new(), + resolved_styles: Vec::new(), + dialogue_info: Vec::new(), + config, + }; + + analysis.resolve_all_styles(); + analysis.analyze_events(); + analysis.run_linting(); + + analysis + } + + /// Get all lint issues found during analysis + #[must_use] + pub fn lint_issues(&self) -> &[LintIssue] { + &self.lint_issues + } + + /// Get resolved styles + #[must_use] + pub fn resolved_styles(&self) -> &[ResolvedStyle<'a>] { + &self.resolved_styles + } + + /// Get dialogue analysis results + #[must_use] + pub fn dialogue_info(&self) -> &[DialogueInfo<'a>] { + &self.dialogue_info + } + + /// Get reference to the analyzed script + #[must_use] + pub const fn script(&self) -> &'a Script<'a> { + self.script + } + + /// Find resolved style by name + #[must_use] + pub fn resolve_style(&self, name: &str) -> Option<&ResolvedStyle<'a>> { + self.resolved_styles.iter().find(|style| style.name == name) + } + + /// Check if script has any critical issues + #[must_use] + pub fn has_critical_issues(&self) -> bool { + self.lint_issues + .iter() + .any(|issue| issue.severity() == linting::IssueSeverity::Critical) + } + + /// Get performance summary + #[must_use] + pub fn performance_summary(&self) -> PerformanceSummary { + PerformanceSummary { + total_events: self.dialogue_info.len(), + overlapping_events: self.count_overlapping_events(), + complex_animations: self.count_complex_animations(), + large_fonts: self.count_large_fonts(), + performance_score: self.calculate_performance_score(), + } + } + + /// Run linting analysis + fn run_linting(&mut self) { + let lint_config = LintConfig::default().with_strict_compliance( + self.config + .options + .contains(ScriptAnalysisOptions::STRICT_COMPLIANCE), + ); + + let mut issues = Vec::new(); + let rules = linting::rules::BuiltinRules::all_rules(); + + for rule in rules { + if !lint_config.is_rule_enabled(rule.id()) { + continue; + } + + let mut rule_issues = rule.check_script(self); + rule_issues.retain(|issue| lint_config.should_report_severity(issue.severity())); + + issues.extend(rule_issues); + + if lint_config.max_issues > 0 && issues.len() >= lint_config.max_issues { + issues.truncate(lint_config.max_issues); + break; + } + } + + self.lint_issues = issues; + } + + /// Resolve all styles with inheritance and overrides + fn resolve_all_styles(&mut self) { + let analyzer = StyleAnalyzer::new(self.script); + self.resolved_styles = analyzer.resolved_styles().values().cloned().collect(); + } + + /// Analyze events for timing, overlaps, and performance + fn analyze_events(&mut self) { + if let Some(Section::Events(events)) = self + .script + .sections() + .iter() + .find(|s| matches!(s, Section::Events(_))) + { + for event in events { + #[cfg(feature = "plugins")] + let info_result = self.registry.map_or_else( + || DialogueInfo::analyze(event), + |registry| DialogueInfo::analyze_with_registry(event, Some(registry)), + ); + + #[cfg(not(feature = "plugins"))] + let info_result = DialogueInfo::analyze(event); + + if let Ok(info) = info_result { + self.dialogue_info.push(info); + } + } + } + } + + /// Count overlapping events using efficient O(n log n) algorithm + fn count_overlapping_events(&self) -> usize { + count_overlapping_dialogue_events(&self.dialogue_info) + } + + /// Count complex animations (transforms, etc.) + fn count_complex_animations(&self) -> usize { + self.dialogue_info + .iter() + .filter(|info| info.animation_score() > 3) + .count() + } + + /// Count fonts larger than reasonable size + fn count_large_fonts(&self) -> usize { + self.resolved_styles + .iter() + .filter(|style| style.font_size() > 72.0) + .count() + } + + /// Calculate overall performance score (0-100) + fn calculate_performance_score(&self) -> u8 { + let mut score = 100u8; + + if self.dialogue_info.len() > 1000 { + score = score.saturating_sub(20); + } else if self.dialogue_info.len() > 500 { + score = score.saturating_sub(10); + } + + let overlaps = self.count_overlapping_events(); + if overlaps > 50 { + score = score.saturating_sub(15); + } else if overlaps > 20 { + score = score.saturating_sub(8); + } + + let animations = self.count_complex_animations(); + if animations > 100 { + score = score.saturating_sub(10); + } else if animations > 50 { + score = score.saturating_sub(5); + } + + let large_fonts = self.count_large_fonts(); + if large_fonts > 10 { + score = score.saturating_sub(5); + } + + score + } +} + +/// Performance analysis summary +#[derive(Debug, Clone)] +pub struct PerformanceSummary { + /// Total number of dialogue events + pub total_events: usize, + + /// Number of overlapping events + pub overlapping_events: usize, + + /// Number of complex animations + pub complex_animations: usize, + + /// Number of oversized fonts + pub large_fonts: usize, + + /// Overall performance score (0-100, higher is better) + pub performance_score: u8, +} + +impl PerformanceSummary { + /// Check if script has performance concerns + #[must_use] + pub const fn has_performance_issues(&self) -> bool { + self.performance_score < 80 + } + + /// Get performance recommendation + #[must_use] + pub const fn recommendation(&self) -> Option<&'static str> { + if self.overlapping_events > 10 { + Some("Consider reducing overlapping events for better performance") + } else if self.complex_animations > 20 { + Some("Many complex animations may impact rendering performance") + } else if self.large_fonts > 5 { + Some("Large font sizes may cause memory issues") + } else if self.total_events > 1000 { + Some("Very large script - consider splitting into multiple files") + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn analysis_config_default() { + let config = AnalysisConfig::default(); + assert!(config + .options + .contains(ScriptAnalysisOptions::UNICODE_LINEBREAKS)); + assert!(config + .options + .contains(ScriptAnalysisOptions::PERFORMANCE_HINTS)); + assert!(!config + .options + .contains(ScriptAnalysisOptions::STRICT_COMPLIANCE)); + assert_eq!(config.max_events_threshold, 1000); + } + + #[test] + fn script_analysis_basic() { + let script_text = r" +[Script Info] +Title: Test Script + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 + +[Events\] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello World! +Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,Second line +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analysis = ScriptAnalysis::analyze(&script).unwrap(); + + assert_eq!(analysis.lint_issues().len(), 0); + assert!(!analysis.has_critical_issues()); + + let perf = analysis.performance_summary(); + assert!(perf.performance_score > 0); + } + + #[test] + fn performance_summary_recommendations() { + let summary = PerformanceSummary { + total_events: 100, + overlapping_events: 15, + complex_animations: 5, + large_fonts: 2, + performance_score: 75, + }; + + assert!(summary.has_performance_issues()); + assert!(summary.recommendation().is_some()); + assert!(summary + .recommendation() + .unwrap() + .contains("overlapping events")); + } +} diff --git a/crates/ass-core/analysis/styles/analyzer.rs b/crates/ass-core/analysis/styles/analyzer.rs new file mode 100644 index 0000000..3fe85a1 --- /dev/null +++ b/crates/ass-core/analysis/styles/analyzer.rs @@ -0,0 +1,1188 @@ +//! Style analyzer for comprehensive ASS script style analysis +//! +//! Provides the main `StyleAnalyzer` interface for resolving styles, detecting +//! conflicts, and performing validation. Orchestrates analysis across multiple +//! sub-modules with efficient caching and zero-copy design. +//! +//! # Features +//! +//! - Comprehensive style resolution with inheritance support +//! - Conflict detection including circular inheritance and duplicates +//! - Performance analysis with configurable thresholds +//! - Validation with multiple severity levels +//! - Zero-copy analysis with lifetime-generic references +//! +//! # Performance +//! +//! - Target: <2ms for complete script style analysis +//! - Memory: Efficient caching with zero-copy style references +//! - Lazy evaluation: Analysis performed only when requested + +use crate::{ + analysis::styles::{ + resolved_style::ResolvedStyle, + validation::{StyleConflict, StyleInheritance, StyleValidationIssue}, + }, + parser::{Script, Section, Style}, +}; +use alloc::{collections::BTreeMap, collections::BTreeSet, vec::Vec}; + +bitflags::bitflags! { + /// Analysis options for style analyzer + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct AnalysisOptions: u8 { + /// Enable inheritance analysis + const INHERITANCE = 1 << 0; + /// Enable conflict detection + const CONFLICTS = 1 << 1; + /// Enable performance analysis + const PERFORMANCE = 1 << 2; + /// Enable value validation + const VALIDATION = 1 << 3; + /// Use strict validation rules + const STRICT_VALIDATION = 1 << 4; + } +} + +/// Comprehensive style analyzer for ASS scripts +/// +/// Orchestrates style analysis including resolution, validation, and conflict +/// detection. Maintains efficient caches for resolved styles and analysis results. +#[derive(Debug)] +pub struct StyleAnalyzer<'a> { + /// Reference to script being analyzed + script: &'a Script<'a>, + /// Cached resolved styles + resolved_styles: BTreeMap<&'a str, ResolvedStyle<'a>>, + /// Style inheritance tracking + inheritance_info: BTreeMap<&'a str, StyleInheritance<'a>>, + /// Detected style conflicts + conflicts: Vec>, + /// Analysis configuration + config: StyleAnalysisConfig, + /// Resolution scaling factor (layout to play resolution) + resolution_scaling: Option<(f32, f32)>, +} + +/// Configuration for style analysis behavior +#[derive(Debug, Clone)] +pub struct StyleAnalysisConfig { + /// Analysis options flags + pub options: AnalysisOptions, + /// Performance analysis thresholds + pub performance_thresholds: PerformanceThresholds, +} + +/// Performance analysis threshold configuration +#[derive(Debug, Clone)] +pub struct PerformanceThresholds { + /// Font size threshold for performance warnings + pub large_font_threshold: f32, + /// Outline thickness threshold + pub large_outline_threshold: f32, + /// Shadow distance threshold + pub large_shadow_threshold: f32, + /// Scaling factor threshold + pub scaling_threshold: f32, +} + +impl Default for StyleAnalysisConfig { + fn default() -> Self { + Self { + options: AnalysisOptions::INHERITANCE + | AnalysisOptions::CONFLICTS + | AnalysisOptions::PERFORMANCE + | AnalysisOptions::VALIDATION, + performance_thresholds: PerformanceThresholds::default(), + } + } +} + +impl Default for PerformanceThresholds { + fn default() -> Self { + Self { + large_font_threshold: 50.0, + large_outline_threshold: 4.0, + large_shadow_threshold: 4.0, + scaling_threshold: 200.0, + } + } +} + +impl<'a> StyleAnalyzer<'a> { + /// Create analyzer with default configuration + #[must_use] + pub fn new(script: &'a Script<'a>) -> Self { + Self::new_with_config(script, StyleAnalysisConfig::default()) + } + + /// Create analyzer with custom configuration + #[must_use] + pub fn new_with_config(script: &'a Script<'a>, config: StyleAnalysisConfig) -> Self { + let mut analyzer = Self { + script, + resolved_styles: BTreeMap::new(), + inheritance_info: BTreeMap::new(), + conflicts: Vec::new(), + config, + resolution_scaling: None, + }; + + analyzer.calculate_resolution_scaling(); + analyzer.analyze_all_styles(); + analyzer + } + + /// Get resolved style by name + #[must_use] + pub fn resolve_style(&self, name: &str) -> Option<&ResolvedStyle<'a>> { + self.resolved_styles.get(name) + } + + /// Get all resolved styles + #[must_use] + pub const fn resolved_styles(&self) -> &BTreeMap<&'a str, ResolvedStyle<'a>> { + &self.resolved_styles + } + + /// Get detected conflicts + #[must_use] + pub fn conflicts(&self) -> &[StyleConflict<'a>] { + &self.conflicts + } + + /// Get inheritance information + #[must_use] + pub const fn inheritance_info(&self) -> &BTreeMap<&'a str, StyleInheritance<'a>> { + &self.inheritance_info + } + + /// Validate all styles and return issues + #[must_use] + pub fn validate_styles(&self) -> Vec { + let mut issues = Vec::new(); + + for resolved in self.resolved_styles.values() { + if self.config.options.contains(AnalysisOptions::VALIDATION) { + issues.extend(self.validate_style_properties(resolved)); + } + + if self.config.options.contains(AnalysisOptions::PERFORMANCE) { + issues.extend(self.analyze_style_performance(resolved)); + } + } + + issues + } + + /// Calculate resolution scaling factor from script info + fn calculate_resolution_scaling(&mut self) { + // Find ScriptInfo section + for section in self.script.sections() { + if let Section::ScriptInfo(script_info) = section { + let layout_res = script_info.layout_resolution(); + let play_res = script_info.play_resolution(); + + if let (Some((layout_x, layout_y)), Some((play_x, play_y))) = (layout_res, play_res) + { + // Only apply scaling if resolutions differ + if layout_x != play_x || layout_y != play_y { + // Scale FROM layout TO play resolution + // If LayoutRes > PlayRes, we scale down (e.g., 1920→640 = 0.333) + // If LayoutRes < PlayRes, we scale up (e.g., 640→1920 = 3.0) + #[allow(clippy::cast_precision_loss)] + let scale_x = play_x as f32 / layout_x as f32; + #[allow(clippy::cast_precision_loss)] + let scale_y = play_y as f32 / layout_y as f32; + self.resolution_scaling = Some((scale_x, scale_y)); + } + } + break; + } + } + } + + /// Analyze all styles in script + fn analyze_all_styles(&mut self) { + for section in self.script.sections() { + if let Section::Styles(styles) = section { + // Build dependency graph and resolve in order + if let Some(ordered_styles) = self.build_dependency_order(styles) { + self.resolve_styles_with_inheritance(&ordered_styles); + } else { + // Fall back to non-inherited resolution if circular dependency detected + for style in styles { + if let Ok(mut resolved) = ResolvedStyle::from_style(style) { + // Apply resolution scaling if needed + if let Some((scale_x, scale_y)) = self.resolution_scaling { + resolved.apply_resolution_scaling(scale_x, scale_y); + } + self.resolved_styles.insert(style.name, resolved); + } + } + } + + if self.config.options.contains(AnalysisOptions::CONFLICTS) { + self.detect_style_conflicts_from_section(styles); + } + break; + } + } + } + + /// Build dependency order for styles using topological sort + /// Returns None if circular dependency is detected + fn build_dependency_order(&mut self, styles: &'a [Style<'a>]) -> Option>> { + // Create style map for quick lookup + let style_map: BTreeMap<&str, &Style> = styles.iter().map(|s| (s.name, s)).collect(); + + // Build adjacency list (child -> parent) + let mut dependencies: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new(); + + // Initialize all styles + for style in styles { + dependencies.insert(style.name, BTreeSet::new()); + in_degree.insert(style.name, 0); + } + + // Build dependency graph + for style in styles { + if let Some(parent_name) = style.parent { + if style_map.contains_key(parent_name) { + dependencies + .get_mut(style.name) + .unwrap() + .insert(parent_name); + *in_degree.get_mut(parent_name).unwrap() += 1; + + // Track inheritance for analysis + if self.config.options.contains(AnalysisOptions::INHERITANCE) { + if let Some(inheritance) = self.inheritance_info.get_mut(style.name) { + inheritance.set_parent(parent_name); + } else { + let mut inheritance = StyleInheritance::new(style.name); + inheritance.set_parent(parent_name); + self.inheritance_info.insert(style.name, inheritance); + } + } + } else { + // Parent style not found - add warning conflict + self.conflicts + .push(StyleConflict::missing_parent(style.name, parent_name)); + } + } else if self.config.options.contains(AnalysisOptions::INHERITANCE) { + // Style has no parent + self.inheritance_info + .insert(style.name, StyleInheritance::new(style.name)); + } + } + + // Check for circular dependencies using DFS + if Self::has_circular_dependency(&dependencies) { + self.conflicts.push(StyleConflict::circular_inheritance( + dependencies.keys().copied().collect(), + )); + return None; + } + + // Perform topological sort + let mut result = Vec::new(); + let mut queue: Vec<&str> = Vec::new(); + + // Find all nodes with no dependencies + for (name, degree) in &in_degree { + if *degree == 0 { + queue.push(name); + } + } + + while let Some(current) = queue.pop() { + if let Some(style) = style_map.get(current) { + result.push(*style); + } + + // Update in-degrees + for (child, parents) in &dependencies { + if parents.contains(current) { + if let Some(degree) = in_degree.get_mut(child) { + *degree = degree.saturating_sub(1); + if *degree == 0 { + queue.push(child); + } + } + } + } + } + + // Check if all styles were processed + if result.len() == styles.len() { + Some(result) + } else { + // Not all styles processed - circular dependency exists + None + } + } + + /// Check for circular dependencies using DFS + fn has_circular_dependency(dependencies: &BTreeMap<&str, BTreeSet<&str>>) -> bool { + let mut visited = BTreeSet::new(); + let mut rec_stack = BTreeSet::new(); + + for node in dependencies.keys() { + if !visited.contains(node) + && Self::dfs_has_cycle(node, dependencies, &mut visited, &mut rec_stack) + { + return true; + } + } + + false + } + + /// DFS helper for cycle detection + fn dfs_has_cycle<'b>( + node: &'b str, + dependencies: &BTreeMap<&'b str, BTreeSet<&'b str>>, + visited: &mut BTreeSet<&'b str>, + rec_stack: &mut BTreeSet<&'b str>, + ) -> bool { + visited.insert(node); + rec_stack.insert(node); + + if let Some(neighbors) = dependencies.get(node) { + for neighbor in neighbors { + if !visited.contains(neighbor) { + if Self::dfs_has_cycle(neighbor, dependencies, visited, rec_stack) { + return true; + } + } else if rec_stack.contains(neighbor) { + return true; + } + } + } + + rec_stack.remove(node); + false + } + + /// Resolve styles with inheritance support + fn resolve_styles_with_inheritance(&mut self, ordered_styles: &[&'a Style<'a>]) { + for style in ordered_styles { + let resolved = if let Some(parent_name) = style.parent { + // Get parent's resolved style + self.resolved_styles.get(parent_name).map_or_else( + || ResolvedStyle::from_style(style), + |parent_resolved| ResolvedStyle::from_style_with_parent(style, parent_resolved), + ) + } else { + // No parent - resolve directly + ResolvedStyle::from_style(style) + }; + + if let Ok(mut resolved_style) = resolved { + // Apply resolution scaling if needed + if let Some((scale_x, scale_y)) = self.resolution_scaling { + resolved_style.apply_resolution_scaling(scale_x, scale_y); + } + self.resolved_styles.insert(style.name, resolved_style); + } + } + } + + /// Extract styles from script sections + #[must_use] + pub fn extract_styles(&self) -> Option<&[Style<'a>]> { + for section in self.script.sections() { + if let Section::Styles(styles) = section { + return Some(styles); + } + } + None + } + + /// Detect conflicts between styles in a section + fn detect_style_conflicts_from_section(&mut self, styles: &[Style<'a>]) { + let mut name_counts: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + + for style in styles { + name_counts.entry(style.name).or_default().push(style.name); + } + + for (_name, instances) in name_counts { + if instances.len() > 1 { + self.conflicts + .push(StyleConflict::duplicate_name(instances)); + } + } + } + + /// Validate style properties + fn validate_style_properties(&self, style: &ResolvedStyle<'a>) -> Vec { + let mut issues = Vec::new(); + + if style.font_size() <= 0.0 { + issues.push(StyleValidationIssue::error( + "font_size", + "Font size must be positive", + )); + } + + if self + .config + .options + .contains(AnalysisOptions::STRICT_VALIDATION) + && style.font_size() > 200.0 + { + issues.push(StyleValidationIssue::warning( + "font_size", + "Very large font size may cause performance issues", + )); + } + + issues + } + + /// Analyze style performance impact + fn analyze_style_performance(&self, style: &ResolvedStyle<'a>) -> Vec { + let mut issues = Vec::new(); + let thresholds = &self.config.performance_thresholds; + + if style.font_size() > thresholds.large_font_threshold { + issues.push(StyleValidationIssue::info_with_suggestion( + "font_size", + "Large font size detected", + "Consider reducing font size for better performance", + )); + } + + if style.has_performance_issues() { + issues.push(StyleValidationIssue::warning( + "complexity", + "Style has high rendering complexity", + )); + } + + issues + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analysis::styles::validation::ConflictType; + #[cfg(not(feature = "std"))] + use alloc::format; + + #[test] + fn analyzer_creation() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + assert_eq!(analyzer.resolved_styles().len(), 1); + assert!(analyzer.resolve_style("Default").is_some()); + } + + #[test] + fn config_defaults() { + let config = StyleAnalysisConfig::default(); + assert!(config.options.contains(AnalysisOptions::INHERITANCE)); + assert!(config.options.contains(AnalysisOptions::CONFLICTS)); + assert!(config.options.contains(AnalysisOptions::VALIDATION)); + assert!(!config.options.contains(AnalysisOptions::STRICT_VALIDATION)); + } + + #[test] + fn performance_thresholds() { + let thresholds = PerformanceThresholds::default(); + assert!((thresholds.large_font_threshold - 50.0).abs() < f32::EPSILON); + assert!((thresholds.large_outline_threshold - 4.0).abs() < f32::EPSILON); + assert!((thresholds.large_shadow_threshold - 4.0).abs() < f32::EPSILON); + assert!((thresholds.scaling_threshold - 200.0).abs() < f32::EPSILON); + } + + #[test] + fn analyzer_with_custom_config() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let config = StyleAnalysisConfig { + options: AnalysisOptions::VALIDATION | AnalysisOptions::STRICT_VALIDATION, + performance_thresholds: PerformanceThresholds { + large_font_threshold: 30.0, + large_outline_threshold: 2.0, + large_shadow_threshold: 2.0, + scaling_threshold: 150.0, + }, + }; + let analyzer = StyleAnalyzer::new_with_config(&script, config); + + assert_eq!(analyzer.resolved_styles().len(), 1); + assert!(analyzer.resolve_style("Default").is_some()); + } + + #[test] + fn analyzer_multiple_styles() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +Style: Title,Arial,32,&H00FFFF00,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,3,0,2,20,20,20,1 +Style: Subtitle,Arial,16,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,1,0,0,100,100,0,0,1,1,0,2,5,5,5,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + assert_eq!(analyzer.resolved_styles().len(), 3); + assert!(analyzer.resolve_style("Default").is_some()); + assert!(analyzer.resolve_style("Title").is_some()); + assert!(analyzer.resolve_style("Subtitle").is_some()); + assert!(analyzer.resolve_style("NonExistent").is_none()); + } + + #[test] + fn analyzer_duplicate_styles() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +Style: Default,Times,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let conflicts = analyzer.conflicts(); + assert!(!conflicts.is_empty()); + } + + #[test] + fn analyzer_no_styles_section() { + let script_text = r" +[Script Info] +Title: Test Script +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + assert_eq!(analyzer.resolved_styles().len(), 0); + assert!(analyzer.conflicts().is_empty()); + } + + #[test] + fn analyzer_empty_styles_section() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + assert_eq!(analyzer.resolved_styles().len(), 0); + assert!(analyzer.conflicts().is_empty()); + } + + #[test] + fn analyzer_extract_styles() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let styles = analyzer.extract_styles(); + assert!(styles.is_some()); + assert_eq!(styles.unwrap().len(), 1); + } + + #[test] + fn analyzer_extract_styles_no_section() { + let script_text = r" +[Script Info] +Title: Test Script +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let styles = analyzer.extract_styles(); + assert!(styles.is_none()); + } + + #[test] + fn analyzer_inheritance_info() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +Style: Title,Arial,32,&H00FFFF00,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,3,0,2,20,20,20,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let inheritance_info = analyzer.inheritance_info(); + assert_eq!(inheritance_info.len(), 2); + assert!(inheritance_info.contains_key("Default")); + assert!(inheritance_info.contains_key("Title")); + } + + #[test] + fn analyzer_validate_styles() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +Style: Large,Arial,60,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,5,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let issues = analyzer.validate_styles(); + // Should have some validation issues or none + assert!(issues.is_empty() || !issues.is_empty()); + } + + #[test] + fn analyzer_strict_validation() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Large,Arial,250,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let config = StyleAnalysisConfig { + options: AnalysisOptions::VALIDATION | AnalysisOptions::STRICT_VALIDATION, + performance_thresholds: PerformanceThresholds::default(), + }; + let analyzer = StyleAnalyzer::new_with_config(&script, config); + + let issues = analyzer.validate_styles(); + // Should have validation issues for large font size + assert!(!issues.is_empty()); + } + + #[test] + fn analyzer_performance_analysis() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Heavy,Arial,60,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,8,5,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let config = StyleAnalysisConfig { + options: AnalysisOptions::PERFORMANCE, + performance_thresholds: PerformanceThresholds { + large_font_threshold: 30.0, + large_outline_threshold: 2.0, + large_shadow_threshold: 2.0, + scaling_threshold: 150.0, + }, + }; + let analyzer = StyleAnalyzer::new_with_config(&script, config); + + let issues = analyzer.validate_styles(); + // Should have performance issues for large values + assert!(!issues.is_empty()); + } + + #[test] + fn analyzer_options_flags() { + let options = AnalysisOptions::INHERITANCE | AnalysisOptions::CONFLICTS; + assert!(options.contains(AnalysisOptions::INHERITANCE)); + assert!(options.contains(AnalysisOptions::CONFLICTS)); + assert!(!options.contains(AnalysisOptions::VALIDATION)); + assert!(!options.contains(AnalysisOptions::PERFORMANCE)); + assert!(!options.contains(AnalysisOptions::STRICT_VALIDATION)); + } + + #[test] + fn analyzer_options_debug() { + let options = AnalysisOptions::INHERITANCE; + let debug_str = format!("{options:?}"); + assert!(debug_str.contains("INHERITANCE")); + } + + #[test] + fn analyzer_config_debug() { + let config = StyleAnalysisConfig::default(); + let debug_str = format!("{config:?}"); + assert!(debug_str.contains("StyleAnalysisConfig")); + assert!(debug_str.contains("options")); + assert!(debug_str.contains("performance_thresholds")); + } + + #[test] + fn analyzer_debug() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + let debug_str = format!("{analyzer:?}"); + assert!(debug_str.contains("StyleAnalyzer")); + } + + #[test] + fn performance_thresholds_debug() { + let thresholds = PerformanceThresholds::default(); + let debug_str = format!("{thresholds:?}"); + assert!(debug_str.contains("PerformanceThresholds")); + assert!(debug_str.contains("large_font_threshold")); + } + + #[test] + fn config_clone() { + let config = StyleAnalysisConfig::default(); + let cloned = config.clone(); + assert_eq!(config.options, cloned.options); + assert!( + (config.performance_thresholds.large_font_threshold + - cloned.performance_thresholds.large_font_threshold) + .abs() + < f32::EPSILON + ); + } + + #[test] + fn performance_thresholds_clone() { + let thresholds = PerformanceThresholds::default(); + let cloned = thresholds.clone(); + assert!( + (thresholds.large_font_threshold - cloned.large_font_threshold).abs() < f32::EPSILON + ); + assert!( + (thresholds.large_outline_threshold - cloned.large_outline_threshold).abs() + < f32::EPSILON + ); + assert!( + (thresholds.large_shadow_threshold - cloned.large_shadow_threshold).abs() + < f32::EPSILON + ); + assert!((thresholds.scaling_threshold - cloned.scaling_threshold).abs() < f32::EPSILON); + } + + #[test] + fn analyzer_minimal_options() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let config = StyleAnalysisConfig { + options: AnalysisOptions::empty(), + performance_thresholds: PerformanceThresholds::default(), + }; + let analyzer = StyleAnalyzer::new_with_config(&script, config); + + assert_eq!(analyzer.resolved_styles().len(), 1); + assert!(analyzer.inheritance_info().is_empty()); + assert!(analyzer.conflicts().is_empty()); + } + + #[test] + fn analyzer_style_inheritance_basic() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: BaseStyle,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +Style: *BaseStyle,DerivedStyle,Verdana,24,&HFF00FFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + assert_eq!(analyzer.resolved_styles().len(), 2); + + let base_style = analyzer.resolve_style("BaseStyle").unwrap(); + assert_eq!(base_style.font_name(), "Arial"); + assert!((base_style.font_size() - 20.0).abs() < f32::EPSILON); + assert!(!base_style.is_bold()); + + let derived_style = analyzer.resolve_style("DerivedStyle").unwrap(); + assert_eq!(derived_style.font_name(), "Verdana"); + assert!((derived_style.font_size() - 24.0).abs() < f32::EPSILON); + assert!(derived_style.is_bold()); + // Should inherit colors from base + assert_eq!(derived_style.primary_color(), [255, 255, 0, 255]); // Overridden + assert_eq!( + derived_style.secondary_color(), + base_style.secondary_color() + ); + assert_eq!(derived_style.outline_color(), base_style.outline_color()); + } + + #[test] + fn analyzer_style_inheritance_partial_override() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: BaseStyle,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,1,0,0,100,100,0,0,1,2,3,2,10,10,10,1 +Style: *BaseStyle,DerivedStyle,Verdana,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,2,3,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let base_style = analyzer.resolve_style("BaseStyle").unwrap(); + let derived_style = analyzer.resolve_style("DerivedStyle").unwrap(); + + // Should override font name + assert_eq!(derived_style.font_name(), "Verdana"); + // Should override font size + assert!((derived_style.font_size() - 24.0).abs() < f32::EPSILON); + // Should inherit colors + assert_eq!(derived_style.primary_color(), base_style.primary_color()); + // Should inherit bold but override italic + assert!(derived_style.is_bold()); + assert!(!derived_style.is_italic()); + // Should inherit shadow + assert!((derived_style.shadow() - 3.0).abs() < f32::EPSILON); + } + + #[test] + fn analyzer_style_inheritance_chain() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: GrandParent,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +Style: *GrandParent,Parent,Verdana,24,&H00FFFF00,&H000000FF,&H00000000,&H00000000,1,0,0,0,100,100,0,0,1,3,0,2,15,15,15,1 +Style: *Parent,Child,Times,28,&H00FF00FF,&H000000FF,&H00000000,&H00000000,1,1,0,0,100,100,0,0,1,4,0,2,20,20,20,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + assert_eq!(analyzer.resolved_styles().len(), 3); + + let grandparent = analyzer.resolve_style("GrandParent").unwrap(); + let parent = analyzer.resolve_style("Parent").unwrap(); + let child = analyzer.resolve_style("Child").unwrap(); + + // GrandParent properties + assert_eq!(grandparent.font_name(), "Arial"); + assert!((grandparent.font_size() - 20.0).abs() < f32::EPSILON); + assert!(!grandparent.is_bold()); + assert!((grandparent.outline() - 2.0).abs() < f32::EPSILON); + + // Parent inherits and overrides + assert_eq!(parent.font_name(), "Verdana"); + assert!((parent.font_size() - 24.0).abs() < f32::EPSILON); + assert!(parent.is_bold()); + assert!((parent.outline() - 3.0).abs() < f32::EPSILON); + assert_eq!(parent.margin_l(), 15); + + // Child inherits from Parent and overrides + assert_eq!(child.font_name(), "Times"); + assert!((child.font_size() - 28.0).abs() < f32::EPSILON); + assert!(child.is_bold()); + assert!(child.is_italic()); + assert!((child.outline() - 4.0).abs() < f32::EPSILON); + assert_eq!(child.margin_l(), 20); + } + + #[test] + fn analyzer_style_inheritance_missing_parent() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: *NonExistent,Orphan,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + // Should still resolve the style without inheritance + assert_eq!(analyzer.resolved_styles().len(), 1); + let orphan = analyzer.resolve_style("Orphan").unwrap(); + assert_eq!(orphan.font_name(), "Arial"); + + // Should have a conflict for missing parent + let conflicts = analyzer.conflicts(); + assert!(!conflicts.is_empty()); + assert!(conflicts + .iter() + .any(|c| matches!(c.conflict_type, ConflictType::MissingReference))); + } + + #[test] + fn analyzer_style_circular_inheritance() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: *StyleB,StyleA,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +Style: *StyleA,StyleB,Verdana,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + // Should still resolve styles without inheritance due to circular dependency + assert_eq!(analyzer.resolved_styles().len(), 2); + + // Should detect circular inheritance + let conflicts = analyzer.conflicts(); + assert!(conflicts + .iter() + .any(|c| matches!(c.conflict_type, ConflictType::CircularInheritance))); + } + + #[test] + fn analyzer_style_self_inheritance() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: *SelfRef,SelfRef,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + // Should resolve without inheritance due to self-reference + assert_eq!(analyzer.resolved_styles().len(), 1); + + // Should detect circular inheritance + let conflicts = analyzer.conflicts(); + assert!(conflicts + .iter() + .any(|c| matches!(c.conflict_type, ConflictType::CircularInheritance))); + } + + #[test] + fn analyzer_inheritance_info_tracking() { + let script_text = r" +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: BaseStyle,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +Style: *BaseStyle,Child1,Verdana,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +Style: *BaseStyle,Child2,Times,18,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let inheritance_info = analyzer.inheritance_info(); + assert_eq!(inheritance_info.len(), 3); + + // Check BaseStyle has no parent + let base_info = inheritance_info.get("BaseStyle").unwrap(); + assert!(base_info.is_root()); + assert!(base_info.parents.is_empty()); + + // Check Child1 has BaseStyle as parent + let child1_info = inheritance_info.get("Child1").unwrap(); + assert!(!child1_info.is_root()); + assert_eq!(child1_info.parents.len(), 1); + assert_eq!(child1_info.parents[0], "BaseStyle"); + + // Check Child2 has BaseStyle as parent + let child2_info = inheritance_info.get("Child2").unwrap(); + assert!(!child2_info.is_root()); + assert_eq!(child2_info.parents.len(), 1); + assert_eq!(child2_info.parents[0], "BaseStyle"); + } + + #[test] + fn analyzer_layout_resolution_scaling() { + let script_text = r" +[Script Info] +Title: Resolution Scaling Test +LayoutResX: 640 +LayoutResY: 480 +PlayResX: 1280 +PlayResY: 960 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,4,2,2,10,10,20,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let default_style = analyzer.resolve_style("Default").unwrap(); + // Resolution is scaled 2x (1280/640 = 2, 960/480 = 2) + assert!((default_style.font_size() - 40.0).abs() < f32::EPSILON); // 20 * 2 + assert!((default_style.spacing() - 4.0).abs() < f32::EPSILON); // 2 * 2 + assert!((default_style.outline() - 8.0).abs() < f32::EPSILON); // 4 * 2 + assert!((default_style.shadow() - 4.0).abs() < f32::EPSILON); // 2 * 2 + assert_eq!(default_style.margin_l(), 20); // 10 * 2 + assert_eq!(default_style.margin_r(), 20); // 10 * 2 + assert_eq!(default_style.margin_t(), 40); // 20 * 2 + assert_eq!(default_style.margin_b(), 40); // 20 * 2 + } + + #[test] + fn analyzer_layout_resolution_scaling_asymmetric() { + let script_text = r" +[Script Info] +Title: Asymmetric Resolution Scaling Test +LayoutResX: 640 +LayoutResY: 480 +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,4,2,2,10,10,20,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let default_style = analyzer.resolve_style("Default").unwrap(); + // X scale: 1920/640 = 3, Y scale: 1080/480 = 2.25, average = 2.625 + let avg_scale = 2.625; + assert!((20.0f32.mul_add(-avg_scale, default_style.font_size())).abs() < 0.01); + assert!((default_style.spacing() - 6.0).abs() < f32::EPSILON); // 2 * 3 + assert!((4.0f32.mul_add(-avg_scale, default_style.outline())).abs() < 0.01); + assert!((2.0f32.mul_add(-avg_scale, default_style.shadow())).abs() < 0.01); + assert_eq!(default_style.margin_l(), 30); // 10 * 3 + assert_eq!(default_style.margin_r(), 30); // 10 * 3 + assert_eq!(default_style.margin_t(), 45); // 20 * 2.25 + assert_eq!(default_style.margin_b(), 45); // 20 * 2.25 + } + + #[test] + fn analyzer_no_resolution_scaling_when_same() { + let script_text = r" +[Script Info] +Title: No Scaling Test +LayoutResX: 1920 +LayoutResY: 1080 +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,4,2,2,10,10,20,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let default_style = analyzer.resolve_style("Default").unwrap(); + // No scaling should be applied + assert!((default_style.font_size() - 20.0).abs() < f32::EPSILON); + assert!((default_style.spacing() - 2.0).abs() < f32::EPSILON); + assert!((default_style.outline() - 4.0).abs() < f32::EPSILON); + assert!((default_style.shadow() - 2.0).abs() < f32::EPSILON); + assert_eq!(default_style.margin_l(), 10); + assert_eq!(default_style.margin_r(), 10); + assert_eq!(default_style.margin_t(), 20); + assert_eq!(default_style.margin_b(), 20); + } + + #[test] + fn analyzer_resolution_scaling_with_inheritance() { + let script_text = r" +[Script Info] +Title: Scaling with Inheritance Test +LayoutResX: 640 +LayoutResY: 480 +PlayResX: 1280 +PlayResY: 960 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Base,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,4,2,2,10,10,20,1 +Style: *Base,Derived,Verdana,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,2,15,15,20,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let base_style = analyzer.resolve_style("Base").unwrap(); + let derived_style = analyzer.resolve_style("Derived").unwrap(); + + // Base style should be scaled 2x + assert!((base_style.font_size() - 40.0).abs() < f32::EPSILON); + + // Derived style overrides font size to 24, which should be scaled to 48 + assert!((derived_style.font_size() - 48.0).abs() < f32::EPSILON); + // Margins are overridden and should be scaled + assert_eq!(derived_style.margin_l(), 30); // 15 * 2 + assert_eq!(derived_style.margin_r(), 30); // 15 * 2 + // Since margin_v is "20", it should be scaled to 40 + assert_eq!(derived_style.margin_t(), 40); // 20 * 2 + assert_eq!(derived_style.margin_b(), 40); // 20 * 2 + } + + #[test] + fn analyzer_no_resolution_info_no_scaling() { + let script_text = r" +[Script Info] +Title: No Resolution Info Test + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,4,2,2,10,10,20,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let default_style = analyzer.resolve_style("Default").unwrap(); + // No scaling should be applied when resolution info is missing + assert!((default_style.font_size() - 20.0).abs() < f32::EPSILON); + assert!((default_style.spacing() - 2.0).abs() < f32::EPSILON); + assert!((default_style.outline() - 4.0).abs() < f32::EPSILON); + assert!((default_style.shadow() - 2.0).abs() < f32::EPSILON); + assert_eq!(default_style.margin_l(), 10); + assert_eq!(default_style.margin_r(), 10); + assert_eq!(default_style.margin_t(), 20); + assert_eq!(default_style.margin_b(), 20); + } + + #[test] + fn analyzer_partial_resolution_info_no_scaling() { + let script_text = r" +[Script Info] +Title: Partial Resolution Info Test +LayoutResX: 640 +PlayResY: 960 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,2,0,1,4,2,2,10,10,20,1 +"; + + let script = crate::parser::Script::parse(script_text).unwrap(); + let analyzer = StyleAnalyzer::new(&script); + + let default_style = analyzer.resolve_style("Default").unwrap(); + // No scaling should be applied when resolution info is incomplete + assert!((default_style.font_size() - 20.0).abs() < f32::EPSILON); + assert!((default_style.spacing() - 2.0).abs() < f32::EPSILON); + assert!((default_style.outline() - 4.0).abs() < f32::EPSILON); + assert!((default_style.shadow() - 2.0).abs() < f32::EPSILON); + assert_eq!(default_style.margin_l(), 10); + assert_eq!(default_style.margin_r(), 10); + assert_eq!(default_style.margin_t(), 20); + assert_eq!(default_style.margin_b(), 20); + } +} diff --git a/crates/ass-core/analysis/styles/mod.rs b/crates/ass-core/analysis/styles/mod.rs new file mode 100644 index 0000000..87759d5 --- /dev/null +++ b/crates/ass-core/analysis/styles/mod.rs @@ -0,0 +1,71 @@ +//! ASS script style analysis and resolution +//! +//! Provides comprehensive style analysis capabilities including inheritance resolution, +//! conflict detection, performance analysis, and validation. Designed for zero-copy +//! efficiency with lifetime-generic references to original style definitions. +//! +//! # Features +//! +//! - **Style Resolution**: Compute effective styles from base definitions and overrides +//! - **Inheritance Analysis**: Track style inheritance chains and detect circular references +//! - **Conflict Detection**: Identify duplicate names, missing references, and conflicts +//! - **Performance Assessment**: Analyze rendering complexity and optimization opportunities +//! - **Validation**: Check property values, font availability, and spec compliance +//! - **Zero-Copy Design**: Minimal allocations via lifetime-generic spans +//! +//! # Performance Targets +//! +//! - Style resolution: <1ms for typical scripts +//! - Memory usage: ~200 bytes per resolved style +//! - Analysis: <2ms for complete script style analysis +//! - Caching: Efficient resolution with zero-copy references +//! +//! # Quick Start +//! +//! ```rust +//! use ass_core::analysis::styles::{StyleAnalyzer, ResolvedStyle}; +//! use ass_core::parser::Script; +//! +//! let script_text = r#" +//! [V4+ Styles] +//! Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +//! Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +//! "#; +//! +//! let script = Script::parse(script_text)?; +//! let analyzer = StyleAnalyzer::new(&script); +//! +//! if let Some(resolved) = analyzer.resolve_style("Default") { +//! println!("Font: {}, Size: {}", resolved.font_name(), resolved.font_size()); +//! println!("Complexity: {}/100", resolved.complexity_score()); +//! println!("Performance issues: {}", resolved.has_performance_issues()); +//! } +//! +//! // Validate all styles +//! let issues = analyzer.validate_styles(); +//! for issue in issues { +//! println!("{}: {} in {}", issue.severity, issue.message, issue.field); +//! } +//! +//! // Check for conflicts +//! for conflict in analyzer.conflicts() { +//! println!("Conflict: {}", conflict.description); +//! } +//! # Ok::<(), Box>(()) +//! ``` +//! +//! # Module Organization +//! +//! - [`resolved_style`] - Fully resolved style representation with computed values +//! - [`validation`] - Style validation, conflict detection, and issue reporting +//! - [`analyzer`] - Main analysis interface orchestrating all style operations + +pub mod analyzer; +pub mod resolved_style; +pub mod validation; + +pub use analyzer::{PerformanceThresholds, StyleAnalysisConfig, StyleAnalyzer}; +pub use resolved_style::ResolvedStyle; +pub use validation::{ + ConflictType, StyleConflict, StyleInheritance, StyleValidationIssue, ValidationSeverity, +}; diff --git a/crates/ass-core/analysis/styles/resolved_style.rs b/crates/ass-core/analysis/styles/resolved_style.rs new file mode 100644 index 0000000..b775e7e --- /dev/null +++ b/crates/ass-core/analysis/styles/resolved_style.rs @@ -0,0 +1,1156 @@ +//! Resolved style representation with computed values and metrics +//! +//! Provides the `ResolvedStyle` struct containing fully computed style properties +//! after applying inheritance, overrides, and default fallbacks. Includes +//! performance analysis and rendering complexity assessment. +//! +//! # Features +//! +//! - Zero-copy style name references to original definitions +//! - Computed RGBA color values for efficient rendering +//! - Performance complexity scoring (0-100 scale) +//! - Font and layout property validation +//! - Memory-efficient representation via packed fields +//! +//! # Performance +//! +//! - Target: <0.1ms per style resolution +//! - Memory: ~200 bytes per resolved style +//! - Zero allocations for style name references + +use crate::{parser::Style, utils::CoreError, Result}; +use alloc::{string::String, string::ToString}; + +bitflags::bitflags! { + /// Text formatting options for resolved styles + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct TextFormatting: u8 { + /// Bold text formatting + const BOLD = 1 << 0; + /// Italic text formatting + const ITALIC = 1 << 1; + /// Underline text formatting + const UNDERLINE = 1 << 2; + /// Strike-through text formatting + const STRIKE_OUT = 1 << 3; + } +} + +/// Fully resolved style with computed values and performance metrics +/// +/// Contains effective style values after applying inheritance, overrides, +/// and defaults. Optimized for rendering with pre-computed color values +/// and complexity scoring for performance assessment. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedStyle<'a> { + /// Original style name (zero-copy reference) + pub name: &'a str, + /// Resolved font family name + font_name: String, + /// Font size in points + font_size: f32, + /// Primary text color (RGBA) + primary_color: [u8; 4], + /// Secondary text color (RGBA) + secondary_color: [u8; 4], + /// Outline color (RGBA) + outline_color: [u8; 4], + /// Background color (RGBA) + back_color: [u8; 4], + /// Text formatting flags + formatting: TextFormatting, + /// Scaling factors (percentage) + /// Horizontal scaling factor + scale_x: f32, + /// Vertical scaling factor + scale_y: f32, + /// Character spacing + spacing: f32, + /// Text rotation angle + angle: f32, + /// Border style (`0=outline+drop_shadow`, `1=opaque_box`) + border_style: u8, + /// Outline thickness + outline: f32, + /// Shadow distance + shadow: f32, + /// Text alignment (1-9, numpad layout) + alignment: u8, + /// Margins in pixels + /// Left margin in pixels + margin_l: u16, + /// Right margin in pixels + margin_r: u16, + /// Top margin in pixels + margin_t: u16, + /// Bottom margin in pixels + margin_b: u16, + /// Text encoding + encoding: u8, + /// Rendering complexity score (0-100) + complexity_score: u8, +} + +impl<'a> ResolvedStyle<'a> { + /// Create `ResolvedStyle` from base Style definition + /// + /// Resolves all style properties, validates values, and computes + /// performance metrics. Invalid values are replaced with defaults. + /// + /// # Arguments + /// + /// * `style` - Base style definition to resolve + /// + /// # Returns + /// + /// Fully resolved style with computed properties. + /// + /// # Example + /// + /// ```rust + /// # use ass_core::analysis::styles::resolved_style::ResolvedStyle; + /// # use ass_core::parser::Style; + /// let style = Style { name: "Default", fontname: "Arial", fontsize: "20", ..Default::default() }; + /// let resolved = ResolvedStyle::from_style(&style)?; + /// assert_eq!(resolved.font_name(), "Arial"); + /// assert_eq!(resolved.font_size(), 20.0); + /// # Ok::<(), Box>(()) + /// ``` + /// + /// # Errors + /// + /// Returns an error if style parsing fails or contains invalid values. + pub fn from_style(style: &'a Style<'a>) -> Result { + let font_name = if style.fontname.is_empty() { + "Arial".to_string() + } else { + style.fontname.to_string() + }; + + let font_size = parse_font_size(style.fontsize)?; + let primary_color = parse_color_with_default(style.primary_colour)?; + let secondary_color = parse_color_with_default(style.secondary_colour)?; + let outline_color = parse_color_with_default(style.outline_colour)?; + let back_color = parse_color_with_default(style.back_colour)?; + + let mut formatting = TextFormatting::empty(); + if parse_bool_flag(style.bold)? { + formatting |= TextFormatting::BOLD; + } + if parse_bool_flag(style.italic)? { + formatting |= TextFormatting::ITALIC; + } + if parse_bool_flag(style.underline)? { + formatting |= TextFormatting::UNDERLINE; + } + if parse_bool_flag(style.strikeout)? { + formatting |= TextFormatting::STRIKE_OUT; + } + + let scale_x = parse_percentage(style.scale_x)?; + let scale_y = parse_percentage(style.scale_y)?; + let spacing = parse_float(style.spacing)?; + let angle = parse_float(style.angle)?; + + let border_style = parse_u8(style.border_style)?; + let outline = parse_float(style.outline)?; + let shadow = parse_float(style.shadow)?; + + let alignment = parse_u8(style.alignment)?; + let margin_l = parse_u16(style.margin_l)?; + let margin_r = parse_u16(style.margin_r)?; + + // Handle v4+ vs v4++ margin formats + let (margin_t, margin_b) = if let (Some(t), Some(b)) = (style.margin_t, style.margin_b) { + // v4++ format with separate top/bottom margins + (parse_u16(t)?, parse_u16(b)?) + } else { + // v4+ format with single vertical margin + let margin_v = parse_u16(style.margin_v)?; + (margin_v, margin_v) + }; + + let encoding = parse_u8(style.encoding)?; + + let resolved = Self { + name: style.name, + font_name, + font_size, + primary_color, + secondary_color, + outline_color, + back_color, + formatting, + scale_x, + scale_y, + spacing, + angle, + border_style, + outline, + shadow, + alignment, + margin_l, + margin_r, + margin_t, + margin_b, + encoding, + complexity_score: 0, // Will be computed + }; + + Ok(Self { + complexity_score: Self::calculate_complexity(&resolved), + ..resolved + }) + } + + /// Get font family name + #[must_use] + pub fn font_name(&self) -> &str { + &self.font_name + } + + /// Get font size in points + #[must_use] + pub const fn font_size(&self) -> f32 { + self.font_size + } + + /// Get primary color as RGBA bytes + #[must_use] + pub const fn primary_color(&self) -> [u8; 4] { + self.primary_color + } + + /// Get rendering complexity score (0-100) + #[must_use] + pub const fn complexity_score(&self) -> u8 { + self.complexity_score + } + + /// Check if style has performance concerns + #[must_use] + pub const fn has_performance_issues(&self) -> bool { + self.complexity_score > 70 + } + + /// Get text formatting flags + #[must_use] + pub const fn formatting(&self) -> TextFormatting { + self.formatting + } + + /// Check if text is bold + #[must_use] + pub const fn is_bold(&self) -> bool { + self.formatting.contains(TextFormatting::BOLD) + } + + /// Check if text is italic + #[must_use] + pub const fn is_italic(&self) -> bool { + self.formatting.contains(TextFormatting::ITALIC) + } + + /// Check if text is underlined + #[must_use] + pub const fn is_underline(&self) -> bool { + self.formatting.contains(TextFormatting::UNDERLINE) + } + + /// Check if text has strike-through + #[must_use] + pub const fn is_strike_out(&self) -> bool { + self.formatting.contains(TextFormatting::STRIKE_OUT) + } + + /// Get left margin in pixels + #[must_use] + pub const fn margin_l(&self) -> u16 { + self.margin_l + } + + /// Get right margin in pixels + #[must_use] + pub const fn margin_r(&self) -> u16 { + self.margin_r + } + + /// Get top margin in pixels + #[must_use] + pub const fn margin_t(&self) -> u16 { + self.margin_t + } + + /// Get bottom margin in pixels + #[must_use] + pub const fn margin_b(&self) -> u16 { + self.margin_b + } + + /// Get outline thickness + #[must_use] + pub const fn outline(&self) -> f32 { + self.outline + } + + /// Get shadow distance + #[must_use] + pub const fn shadow(&self) -> f32 { + self.shadow + } + + /// Get secondary color as RGBA bytes + #[must_use] + pub const fn secondary_color(&self) -> [u8; 4] { + self.secondary_color + } + + /// Get outline color as RGBA bytes + #[must_use] + pub const fn outline_color(&self) -> [u8; 4] { + self.outline_color + } + + /// Get character spacing + #[must_use] + pub const fn spacing(&self) -> f32 { + self.spacing + } + + /// Get text rotation angle + #[must_use] + pub const fn angle(&self) -> f32 { + self.angle + } + + /// Create resolved style with inheritance from parent + /// + /// # Arguments + /// + /// * `style` - Style definition with possible overrides + /// * `parent` - Parent style to inherit from + /// + /// # Returns + /// + /// Resolved style inheriting parent properties with child overrides + /// + /// # Errors + /// + /// Returns an error if style parsing fails + #[allow(clippy::cognitive_complexity)] + pub fn from_style_with_parent(style: &'a Style<'a>, parent: &Self) -> Result { + // Start with parent properties + let mut resolved = parent.clone(); + + // Update name to child's name + resolved.name = style.name; + + // Override properties that are not empty/default in child + if !style.fontname.is_empty() { + resolved.font_name = style.fontname.to_string(); + } + + if !style.fontsize.is_empty() && style.fontsize != "0" { + resolved.font_size = parse_font_size(style.fontsize)?; + } + + if !style.primary_colour.is_empty() { + resolved.primary_color = parse_color_with_default(style.primary_colour)?; + } + + if !style.secondary_colour.is_empty() { + resolved.secondary_color = parse_color_with_default(style.secondary_colour)?; + } + + if !style.outline_colour.is_empty() { + resolved.outline_color = parse_color_with_default(style.outline_colour)?; + } + + if !style.back_colour.is_empty() { + resolved.back_color = parse_color_with_default(style.back_colour)?; + } + + // For formatting flags, only override if value is non-empty + let mut formatting = resolved.formatting; + if !style.bold.is_empty() { + if style.bold == "0" { + formatting &= !TextFormatting::BOLD; + } else if style.bold == "1" { + formatting |= TextFormatting::BOLD; + } + } + if !style.italic.is_empty() { + if style.italic == "0" { + formatting &= !TextFormatting::ITALIC; + } else if style.italic == "1" { + formatting |= TextFormatting::ITALIC; + } + } + if !style.underline.is_empty() { + if style.underline == "0" { + formatting &= !TextFormatting::UNDERLINE; + } else if style.underline == "1" { + formatting |= TextFormatting::UNDERLINE; + } + } + if !style.strikeout.is_empty() { + if style.strikeout == "0" { + formatting &= !TextFormatting::STRIKE_OUT; + } else if style.strikeout == "1" { + formatting |= TextFormatting::STRIKE_OUT; + } + } + resolved.formatting = formatting; + + if !style.scale_x.is_empty() && style.scale_x != "100" { + resolved.scale_x = parse_percentage(style.scale_x)?; + } + + if !style.scale_y.is_empty() && style.scale_y != "100" { + resolved.scale_y = parse_percentage(style.scale_y)?; + } + + if !style.spacing.is_empty() && style.spacing != "0" { + resolved.spacing = parse_float(style.spacing)?; + } + + if !style.angle.is_empty() && style.angle != "0" { + resolved.angle = parse_float(style.angle)?; + } + + if !style.border_style.is_empty() { + resolved.border_style = parse_u8(style.border_style)?; + } + + if !style.outline.is_empty() && style.outline != "0" { + resolved.outline = parse_float(style.outline)?; + } + + if !style.shadow.is_empty() && style.shadow != "0" { + resolved.shadow = parse_float(style.shadow)?; + } + + if !style.alignment.is_empty() { + resolved.alignment = parse_u8(style.alignment)?; + } + + if !style.margin_l.is_empty() { + resolved.margin_l = parse_u16(style.margin_l)?; + } + + if !style.margin_r.is_empty() { + resolved.margin_r = parse_u16(style.margin_r)?; + } + + // Handle margin inheritance + if let (Some(t), Some(b)) = (style.margin_t, style.margin_b) { + if !t.is_empty() { + resolved.margin_t = parse_u16(t)?; + } + if !b.is_empty() { + resolved.margin_b = parse_u16(b)?; + } + } else if !style.margin_v.is_empty() && style.margin_v != "0" { + let margin_v = parse_u16(style.margin_v)?; + resolved.margin_t = margin_v; + resolved.margin_b = margin_v; + } + // If margin_v is empty or "0", keep inherited margins + + if !style.encoding.is_empty() { + resolved.encoding = parse_u8(style.encoding)?; + } + + // Recalculate complexity score + resolved.complexity_score = Self::calculate_complexity(&resolved); + + Ok(resolved) + } + + /// Apply resolution scaling to coordinate-based properties + /// + /// Scales font size, spacing, outline, shadow, and margins based on the + /// resolution difference between layout and play resolutions. + /// + /// # Arguments + /// + /// * `scale_x` - Horizontal scaling factor (`PlayResX` / `LayoutResX`) + /// * `scale_y` - Vertical scaling factor (`PlayResY` / `LayoutResY`) + pub fn apply_resolution_scaling(&mut self, scale_x: f32, scale_y: f32) { + // Scale font size (use average of X/Y scaling to maintain aspect ratio) + let avg_scale = (scale_x + scale_y) / 2.0; + self.font_size *= avg_scale; + + // Scale spacing (horizontal) + self.spacing *= scale_x; + + // Scale outline and shadow (use average scaling) + self.outline *= avg_scale; + self.shadow *= avg_scale; + + // Scale margins + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + { + self.margin_l = (f32::from(self.margin_l) * scale_x) as u16; + self.margin_r = (f32::from(self.margin_r) * scale_x) as u16; + self.margin_t = (f32::from(self.margin_t) * scale_y) as u16; + self.margin_b = (f32::from(self.margin_b) * scale_y) as u16; + } + + // Recalculate complexity score after scaling + self.complexity_score = Self::calculate_complexity(self); + } + + /// Calculate rendering complexity score + fn calculate_complexity(style: &Self) -> u8 { + const EPSILON: f32 = 0.001; + let mut score = 0u8; + + if style.font_size > 72.0 { + score += 20; + } else if style.font_size > 48.0 { + score += 10; + } + + if style.outline > 4.0 { + score += 15; + } else if style.outline > 2.0 { + score += 8; + } + + if style.shadow > 3.0 { + score += 10; + } else if style.shadow > 1.0 { + score += 5; + } + + if (style.scale_x - 100.0).abs() > EPSILON || (style.scale_y - 100.0).abs() > EPSILON { + score += 10; + } + + if style.angle.abs() > EPSILON { + score += 15; + } + + if style.formatting.contains(TextFormatting::BOLD) { + score += 2; + } + if style.formatting.contains(TextFormatting::ITALIC) { + score += 2; + } + if style + .formatting + .intersects(TextFormatting::UNDERLINE | TextFormatting::STRIKE_OUT) + { + score += 5; + } + + score.min(100) + } +} + +/// Parse font size with validation +fn parse_font_size(size_str: &str) -> Result { + let size = parse_float(size_str)?; + if size <= 0.0 || size > 1000.0 { + Err(CoreError::parse("Invalid font size")) + } else { + Ok(size) + } +} + +/// Parse color value with default handling for empty strings +fn parse_color_with_default(color_str: &str) -> Result<[u8; 4]> { + if color_str.trim().is_empty() { + Ok([255, 255, 255, 255]) // Default white with full alpha + } else { + crate::utils::parse_bgr_color(color_str) + } +} + +/// Parse boolean flag (0 or 1) +fn parse_bool_flag(flag_str: &str) -> Result { + match flag_str { + "0" => Ok(false), + "1" => Ok(true), + _ => Err(CoreError::parse("Invalid boolean flag")), + } +} + +/// Parse percentage value +fn parse_percentage(percent_str: &str) -> Result { + let value = parse_float(percent_str)?; + if (0.0..=1000.0).contains(&value) { + Ok(value) + } else { + Err(CoreError::parse("Invalid percentage")) + } +} + +/// Parse float value with validation +fn parse_float(float_str: &str) -> Result { + float_str + .parse::() + .map_err(|_| CoreError::parse("Invalid float value")) +} + +/// Parse u8 value with validation +fn parse_u8(u8_str: &str) -> Result { + u8_str + .parse::() + .map_err(|_| CoreError::parse("Invalid u8 value")) +} + +/// Parse u16 value with validation +fn parse_u16(u16_str: &str) -> Result { + u16_str + .parse::() + .map_err(|_| CoreError::parse("Invalid u16 value")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::ast::Span; + #[cfg(not(feature = "std"))] + fn create_test_style() -> Style<'static> { + Style { + name: "Test", + parent: None, + fontname: "Arial", + fontsize: "20", + primary_colour: "&H00FFFFFF", + secondary_colour: "&H000000FF", + outline_colour: "&H00000000", + back_colour: "&H00000000", + bold: "0", + italic: "0", + underline: "0", + strikeout: "0", + scale_x: "100", + scale_y: "100", + spacing: "0", + angle: "0", + border_style: "1", + outline: "2", + shadow: "0", + alignment: "2", + margin_l: "10", + margin_r: "10", + margin_v: "10", + margin_t: None, + margin_b: None, + encoding: "1", + relative_to: None, + span: Span::new(0, 0, 0, 0), + } + } + + #[cfg(feature = "std")] + fn create_test_style() -> Style<'static> { + Style { + name: "Test", + parent: None, + fontname: "Arial", + fontsize: "20", + primary_colour: "&H00FFFFFF", + secondary_colour: "&H000000FF", + outline_colour: "&H00000000", + back_colour: "&H00000000", + bold: "0", + italic: "0", + underline: "0", + strikeout: "0", + scale_x: "100", + scale_y: "100", + spacing: "0", + angle: "0", + border_style: "1", + outline: "2", + shadow: "0", + alignment: "2", + margin_l: "10", + margin_r: "10", + margin_v: "10", + margin_t: None, + margin_b: None, + encoding: "1", + relative_to: None, + span: Span::new(0, 0, 0, 0), + } + } + + #[test] + fn resolved_style_creation() { + let style = create_test_style(); + let resolved = ResolvedStyle::from_style(&style).unwrap(); + + assert_eq!(resolved.name, "Test"); + assert_eq!(resolved.font_name(), "Arial"); + assert!((resolved.font_size() - 20.0).abs() < f32::EPSILON); + assert_eq!(resolved.primary_color(), [255, 255, 255, 0]); + } + + #[test] + fn color_parsing() { + // ASS colors are in BGR format: &HAABBGGRR where AA=alpha, BB=blue, GG=green, RR=red + assert_eq!( + crate::utils::parse_bgr_color("&H000000FF").unwrap(), + [255, 0, 0, 0] + ); // Red: RR=FF + assert_eq!( + crate::utils::parse_bgr_color("&H0000FF00").unwrap(), + [0, 255, 0, 0] + ); // Green: GG=FF + assert_eq!( + crate::utils::parse_bgr_color("&H00FF0000").unwrap(), + [0, 0, 255, 0] + ); // Blue: BB=FF + + // Test case-insensitive prefix + assert_eq!( + crate::utils::parse_bgr_color("&h000000FF").unwrap(), + [255, 0, 0, 0] + ); // Red with lowercase h + + // Test 6-digit format (no alpha channel) + assert_eq!( + crate::utils::parse_bgr_color("&HFF0000").unwrap(), + [0, 0, 255, 0] + ); // Blue in 6-digit + assert_eq!( + crate::utils::parse_bgr_color("&H00FF00").unwrap(), + [0, 255, 0, 0] + ); // Green in 6-digit + assert_eq!( + crate::utils::parse_bgr_color("&H0000FF").unwrap(), + [255, 0, 0, 0] + ); // Red in 6-digit + } + + #[test] + fn complexity_scoring() { + let mut style = create_test_style(); + + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.complexity_score() < 50); + + style.fontsize = "100"; + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.complexity_score() >= 20); + } + + #[test] + fn performance_issues_detection() { + let mut style = create_test_style(); + + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(!resolved.has_performance_issues()); + + // Create a style with multiple performance-affecting properties + style.fontsize = "120"; // >72: +20 points + style.outline = "8"; // >4: +15 points + style.shadow = "5"; // >3: +10 points + style.angle = "45"; // !=0: +15 points + style.scale_x = "150"; // !=100: +10 points + style.bold = "1"; // +2 points + style.italic = "1"; // +2 points + style.underline = "1"; // +5 points + // Total: 79 points > 70 threshold + + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.has_performance_issues()); + } + + #[test] + fn parse_font_size_edge_cases() { + // Test invalid font sizes + assert!(parse_font_size("-10").is_err()); // Negative + assert!(parse_font_size("0").is_err()); // Zero + assert!(parse_font_size("1001").is_err()); // Too large + assert!(parse_font_size("abc").is_err()); // Non-numeric + assert!(parse_font_size("").is_err()); // Empty + + // Test valid font sizes + assert!(parse_font_size("1").is_ok()); + assert!(parse_font_size("72").is_ok()); + assert!(parse_font_size("1000").is_ok()); + } + + #[test] + fn parse_color_with_default_invalid_formats() { + // Test invalid color formats + assert!(parse_color_with_default("invalid").is_err()); + assert!(parse_color_with_default("&H").is_err()); + assert!(parse_color_with_default("&HZZZZZ").is_err()); + assert!(parse_color_with_default("12345G").is_err()); // Invalid hex character + + // Test empty string returns default + let default_color = parse_color_with_default("").unwrap(); + assert_eq!(default_color, [255, 255, 255, 255]); + + // Test whitespace only returns default + let whitespace_color = parse_color_with_default(" ").unwrap(); + assert_eq!(whitespace_color, [255, 255, 255, 255]); + } + + #[test] + fn parse_bool_flag_invalid_values() { + // Test invalid boolean flags + assert!(parse_bool_flag("2").is_err()); + assert!(parse_bool_flag("-1").is_err()); + assert!(parse_bool_flag("true").is_err()); + assert!(parse_bool_flag("false").is_err()); + assert!(parse_bool_flag("yes").is_err()); + assert!(parse_bool_flag("no").is_err()); + assert!(parse_bool_flag("").is_err()); + + // Test valid boolean flags + assert!(!parse_bool_flag("0").unwrap()); + assert!(parse_bool_flag("1").unwrap()); + } + + #[test] + #[allow(clippy::float_cmp)] + fn parse_percentage_invalid_values() { + // Test invalid percentages + assert!(parse_percentage("-10").is_err()); // Negative + assert!(parse_percentage("1001").is_err()); // Too large + assert!(parse_percentage("abc").is_err()); // Non-numeric + assert!(parse_percentage("").is_err()); // Empty + + // Test valid percentages + assert_eq!(parse_percentage("0").unwrap(), 0.0); + assert_eq!(parse_percentage("100").unwrap(), 100.0); + assert_eq!(parse_percentage("1000").unwrap(), 1000.0); + } + + #[test] + #[allow(clippy::float_cmp)] + fn parse_float_invalid_values() { + assert!(parse_float("abc").is_err()); + assert!(parse_float("").is_err()); + assert!(parse_float("1.2.3").is_err()); + assert!(parse_float("1.2.3.4").is_err()); + assert!(parse_float("not_a_number").is_err()); + + // Test valid floats + assert_eq!(parse_float("0").unwrap(), 0.0); + assert_eq!(parse_float("-10.5").unwrap(), -10.5); + assert_eq!(parse_float("123.456").unwrap(), 123.456); + } + + #[test] + fn parse_u8_invalid_values() { + assert!(parse_u8("256").is_err()); // Too large + assert!(parse_u8("-1").is_err()); // Negative + assert!(parse_u8("abc").is_err()); // Non-numeric + assert!(parse_u8("").is_err()); // Empty + + // Test valid u8 values + assert_eq!(parse_u8("0").unwrap(), 0); + assert_eq!(parse_u8("255").unwrap(), 255); + } + + #[test] + fn parse_u16_invalid_values() { + assert!(parse_u16("65536").is_err()); // Too large + assert!(parse_u16("-1").is_err()); // Negative + assert!(parse_u16("abc").is_err()); // Non-numeric + assert!(parse_u16("").is_err()); // Empty + + // Test valid u16 values + assert_eq!(parse_u16("0").unwrap(), 0); + assert_eq!(parse_u16("65535").unwrap(), 65535); + } + + #[test] + fn resolved_style_from_style_with_invalid_values() { + let mut style = create_test_style(); + + // Test with invalid font size - should return error + style.fontsize = "-10"; + assert!(ResolvedStyle::from_style(&style).is_err()); + + style.fontsize = "abc"; + assert!(ResolvedStyle::from_style(&style).is_err()); + + // Test with invalid color - should return error + style.fontsize = "20"; // Reset to valid + style.primary_colour = "invalid_color"; + assert!(ResolvedStyle::from_style(&style).is_err()); + + // Test with invalid boolean flag - should return error + style.primary_colour = "&HFFFFFF"; // Reset to valid + style.bold = "2"; + assert!(ResolvedStyle::from_style(&style).is_err()); + } + + #[test] + fn complexity_calculation_all_branches() { + let mut style = create_test_style(); + + // Test baseline complexity + let resolved = ResolvedStyle::from_style(&style).unwrap(); + let baseline_score = resolved.complexity_score(); + + // Test font size increases complexity + style.fontsize = "100"; // Large font size + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.complexity_score() > baseline_score); + + // Test outline increases complexity + style = create_test_style(); // Reset + style.outline = "5"; // Large outline + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.complexity_score() > baseline_score); + + // Test shadow increases complexity + style = create_test_style(); // Reset + style.shadow = "5"; // Large shadow + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.complexity_score() > baseline_score); + + // Test scaling increases complexity + style = create_test_style(); // Reset + style.scale_x = "200"; // Non-default scaling + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.complexity_score() > baseline_score); + + // Test angle increases complexity + style = create_test_style(); // Reset + style.angle = "45"; // Rotation + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.complexity_score() > baseline_score); + + // Test formatting flags increase complexity + style = create_test_style(); // Reset + style.bold = "1"; + style.italic = "1"; + style.underline = "1"; + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.complexity_score() > baseline_score); + } + + #[test] + fn complexity_score_capped_at_100() { + let mut style = create_test_style(); + + // Set all properties to maximum complexity values + style.fontsize = "200"; // Large font + style.outline = "10"; // Large outline + style.shadow = "10"; // Large shadow + style.scale_x = "200"; // Large scaling + style.angle = "180"; // Large rotation + style.bold = "1"; + style.italic = "1"; + style.underline = "1"; + style.strikeout = "1"; + + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.complexity_score() <= 100); // Should be capped at 100 + assert!(resolved.complexity_score() > 50); // Should be high complexity + } + + #[test] + fn text_formatting_flags_comprehensive() { + let mut style = create_test_style(); + + // Test all formatting combinations + style.bold = "1"; + style.italic = "0"; + style.underline = "0"; + style.strikeout = "0"; + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.is_bold()); + assert!(!resolved.is_italic()); + assert!(!resolved.is_underline()); + assert!(!resolved.is_strike_out()); + assert_eq!(resolved.formatting(), TextFormatting::BOLD); + + // Test italic only + style.bold = "0"; + style.italic = "1"; + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(!resolved.is_bold()); + assert!(resolved.is_italic()); + assert_eq!(resolved.formatting(), TextFormatting::ITALIC); + + // Test underline only + style.italic = "0"; + style.underline = "1"; + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.is_underline()); + assert_eq!(resolved.formatting(), TextFormatting::UNDERLINE); + + // Test strikeout only + style.underline = "0"; + style.strikeout = "1"; + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.is_strike_out()); + assert_eq!(resolved.formatting(), TextFormatting::STRIKE_OUT); + + // Test all flags combined + style.bold = "1"; + style.italic = "1"; + style.underline = "1"; + style.strikeout = "1"; + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!(resolved.is_bold()); + assert!(resolved.is_italic()); + assert!(resolved.is_underline()); + assert!(resolved.is_strike_out()); + let expected = TextFormatting::BOLD + | TextFormatting::ITALIC + | TextFormatting::UNDERLINE + | TextFormatting::STRIKE_OUT; + assert_eq!(resolved.formatting(), expected); + } + + #[test] + fn resolved_style_empty_font_name_uses_default() { + let mut style = create_test_style(); + style.fontname = ""; + + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert_eq!(resolved.font_name(), "Arial"); + } + + #[test] + #[allow(clippy::float_cmp)] + fn resolved_style_getters_comprehensive() { + let style = create_test_style(); + let resolved = ResolvedStyle::from_style(&style).unwrap(); + + // Test all getter methods + assert_eq!(resolved.font_name(), "Arial"); + assert_eq!(resolved.font_size(), 20.0); + assert_eq!(resolved.primary_color(), [255, 255, 255, 0]); // &H00FFFFFF + assert!(!resolved.has_performance_issues()); // Low complexity + + let formatting = resolved.formatting(); + assert!(!resolved.is_bold()); + assert!(!resolved.is_italic()); + assert!(!resolved.is_underline()); + assert!(!resolved.is_strike_out()); + assert_eq!(formatting, TextFormatting::empty()); + } + + #[test] + fn resolved_style_apply_resolution_scaling_symmetric() { + let style = create_test_style(); + let mut resolved = ResolvedStyle::from_style(&style).unwrap(); + + // Apply 2x scaling + resolved.apply_resolution_scaling(2.0, 2.0); + + assert!((resolved.font_size() - 40.0).abs() < f32::EPSILON); // 20 * 2 + assert!((resolved.spacing() - 0.0).abs() < f32::EPSILON); // 0 * 2 + assert!((resolved.outline() - 4.0).abs() < f32::EPSILON); // 2 * 2 + assert!((resolved.shadow() - 0.0).abs() < f32::EPSILON); // 0 * 2 + assert_eq!(resolved.margin_l(), 20); // 10 * 2 + assert_eq!(resolved.margin_r(), 20); // 10 * 2 + assert_eq!(resolved.margin_t(), 20); // 10 * 2 + assert_eq!(resolved.margin_b(), 20); // 10 * 2 + } + + #[test] + fn resolved_style_apply_resolution_scaling_asymmetric() { + let mut style = create_test_style(); + style.spacing = "4"; + style.shadow = "2"; + style.margin_l = "10"; + style.margin_r = "20"; + style.margin_v = "30"; + + let mut resolved = ResolvedStyle::from_style(&style).unwrap(); + + // Apply asymmetric scaling (3x horizontal, 2x vertical) + resolved.apply_resolution_scaling(3.0, 2.0); + + // Average scale for font/outline/shadow: (3 + 2) / 2 = 2.5 + assert!((resolved.font_size() - 50.0).abs() < f32::EPSILON); // 20 * 2.5 + assert!((resolved.spacing() - 12.0).abs() < f32::EPSILON); // 4 * 3 + assert!((resolved.outline() - 5.0).abs() < f32::EPSILON); // 2 * 2.5 + assert!((resolved.shadow() - 5.0).abs() < f32::EPSILON); // 2 * 2.5 + assert_eq!(resolved.margin_l(), 30); // 10 * 3 + assert_eq!(resolved.margin_r(), 60); // 20 * 3 + assert_eq!(resolved.margin_t(), 60); // 30 * 2 + assert_eq!(resolved.margin_b(), 60); // 30 * 2 + } + + #[test] + fn resolved_style_apply_resolution_scaling_downscale() { + let style = create_test_style(); + let mut resolved = ResolvedStyle::from_style(&style).unwrap(); + + // Apply 0.5x scaling (downscale) + resolved.apply_resolution_scaling(0.5, 0.5); + + assert!((resolved.font_size() - 10.0).abs() < f32::EPSILON); // 20 * 0.5 + assert!((resolved.spacing() - 0.0).abs() < f32::EPSILON); // 0 * 0.5 + assert!((resolved.outline() - 1.0).abs() < f32::EPSILON); // 2 * 0.5 + assert!((resolved.shadow() - 0.0).abs() < f32::EPSILON); // 0 * 0.5 + assert_eq!(resolved.margin_l(), 5); // 10 * 0.5 + assert_eq!(resolved.margin_r(), 5); // 10 * 0.5 + assert_eq!(resolved.margin_t(), 5); // 10 * 0.5 + assert_eq!(resolved.margin_b(), 5); // 10 * 0.5 + } + + #[test] + fn resolved_style_apply_resolution_scaling_updates_complexity() { + let mut style = create_test_style(); + style.fontsize = "30"; // Not quite large enough to trigger complexity + + let mut resolved = ResolvedStyle::from_style(&style).unwrap(); + let initial_complexity = resolved.complexity_score(); + + // Apply 3x scaling to push font size over complexity threshold + resolved.apply_resolution_scaling(3.0, 3.0); + + assert!((resolved.font_size() - 90.0).abs() < f32::EPSILON); // 30 * 3 + assert!(resolved.complexity_score() > initial_complexity); // Should increase due to large font + } + + #[test] + fn resolved_style_apply_resolution_scaling_preserves_other_properties() { + let mut style = create_test_style(); + style.bold = "1"; + style.italic = "1"; + style.primary_colour = "&H00FF0000"; // Red + style.angle = "45"; + + let mut resolved = ResolvedStyle::from_style(&style).unwrap(); + let initial_color = resolved.primary_color(); + let initial_angle = resolved.angle; + let initial_formatting = resolved.formatting(); + + // Apply scaling + resolved.apply_resolution_scaling(2.0, 2.0); + + // These properties should not be affected by scaling + assert_eq!(resolved.primary_color(), initial_color); + assert!((resolved.angle - initial_angle).abs() < f32::EPSILON); + assert_eq!(resolved.formatting(), initial_formatting); + assert!(resolved.is_bold()); + assert!(resolved.is_italic()); + } + + #[test] + fn resolved_style_spacing_getter() { + let mut style = create_test_style(); + style.spacing = "5.5"; + + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!((resolved.spacing() - 5.5).abs() < f32::EPSILON); + } + + #[test] + fn resolved_style_angle_getter() { + let mut style = create_test_style(); + style.angle = "45.5"; + + let resolved = ResolvedStyle::from_style(&style).unwrap(); + assert!((resolved.angle() - 45.5).abs() < f32::EPSILON); + } +} diff --git a/crates/ass-core/analysis/styles/validation.rs b/crates/ass-core/analysis/styles/validation.rs new file mode 100644 index 0000000..8e9bb7e --- /dev/null +++ b/crates/ass-core/analysis/styles/validation.rs @@ -0,0 +1,317 @@ +//! Style validation and conflict detection for ASS subtitle styles +//! +//! Provides comprehensive validation of style definitions including property +//! validation, inheritance analysis, and conflict detection. Designed for +//! editor integration and script quality assurance. +//! +//! # Features +//! +//! - Property validation with configurable severity levels +//! - Circular inheritance detection and resolution +//! - Duplicate name and missing reference detection +//! - Performance impact assessment for validation issues +//! - Zero-copy validation with lifetime-generic references +//! +//! # Performance +//! +//! - Target: <0.5ms per style validation +//! - Memory: Minimal allocations via zero-copy issue references +//! - Validation: Configurable depth limits for inheritance analysis + +use alloc::{format, string::String, string::ToString, vec, vec::Vec}; +use core::fmt; + +/// Severity level for style validation issues +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ValidationSeverity { + /// Informational message about style properties + Info, + /// Warning about potential rendering or performance issues + Warning, + /// Error that violates ASS specification or causes problems + Error, +} + +impl fmt::Display for ValidationSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Info => write!(f, "info"), + Self::Warning => write!(f, "warning"), + Self::Error => write!(f, "error"), + } + } +} + +/// Style validation issue with context and suggestions +#[derive(Debug, Clone)] +pub struct StyleValidationIssue { + /// Issue severity level + pub severity: ValidationSeverity, + /// Human-readable issue description + pub message: String, + /// Style field that caused the issue + pub field: String, + /// Optional suggested fix or improvement + pub suggestion: Option, +} + +/// Style inheritance tracking and analysis +#[derive(Debug, Clone)] +pub struct StyleInheritance<'a> { + /// Style name (zero-copy reference) + pub name: &'a str, + /// Parent styles in inheritance chain + pub parents: Vec<&'a str>, + /// Child styles that inherit from this style + pub children: Vec<&'a str>, + /// Inheritance depth (0 = root style, no parents) + pub depth: usize, + /// Whether circular inheritance was detected + pub has_circular_inheritance: bool, +} + +/// Style conflict detection and classification +#[derive(Debug, Clone)] +pub struct StyleConflict<'a> { + /// Names of conflicting styles + pub styles: Vec<&'a str>, + /// Type of conflict detected + pub conflict_type: ConflictType, + /// Detailed conflict description + pub description: String, + /// Severity of the conflict + pub severity: ValidationSeverity, +} + +/// Types of style conflicts that can be detected +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictType { + /// Multiple styles with identical names + DuplicateName, + /// Circular reference in style inheritance + CircularInheritance, + /// Conflicting property values between related styles + PropertyConflict, + /// Reference to non-existent style + MissingReference, +} + +impl StyleValidationIssue { + /// Create new validation issue + #[must_use] + pub fn new( + severity: ValidationSeverity, + field: &str, + message: &str, + suggestion: Option<&str>, + ) -> Self { + Self { + severity, + message: message.to_string(), + field: field.to_string(), + suggestion: suggestion.map(ToString::to_string), + } + } + + /// Create error-level issue + #[must_use] + pub fn error(field: &str, message: &str) -> Self { + Self::new(ValidationSeverity::Error, field, message, None) + } + + /// Create warning-level issue + #[must_use] + pub fn warning(field: &str, message: &str) -> Self { + Self::new(ValidationSeverity::Warning, field, message, None) + } + + /// Create info-level issue with suggestion + #[must_use] + pub fn info_with_suggestion(field: &str, message: &str, suggestion: &str) -> Self { + Self::new(ValidationSeverity::Info, field, message, Some(suggestion)) + } +} + +impl<'a> StyleInheritance<'a> { + /// Create new inheritance tracker for style + #[must_use] + pub const fn new(name: &'a str) -> Self { + Self { + name, + parents: Vec::new(), + children: Vec::new(), + depth: 0, + has_circular_inheritance: false, + } + } + + /// Add parent style to inheritance chain + pub fn add_parent(&mut self, parent: &'a str) { + if !self.parents.contains(&parent) { + self.parents.push(parent); + } + } + + /// Set parent style (for single inheritance) + pub fn set_parent(&mut self, parent: &'a str) { + self.parents.clear(); + self.parents.push(parent); + self.depth = 1; // Will be adjusted later based on parent's depth + } + + /// Add child style that inherits from this one + pub fn add_child(&mut self, child: &'a str) { + if !self.children.contains(&child) { + self.children.push(child); + } + } + + /// Check if style has inheritance relationships + #[must_use] + pub fn has_inheritance(&self) -> bool { + !self.parents.is_empty() || !self.children.is_empty() + } + + /// Check if style is root (no parents) + #[must_use] + pub fn is_root(&self) -> bool { + self.parents.is_empty() + } + + /// Check if style is leaf (no children) + #[must_use] + pub fn is_leaf(&self) -> bool { + self.children.is_empty() + } +} + +impl<'a> StyleConflict<'a> { + /// Create new style conflict + #[must_use] + pub fn new( + conflict_type: ConflictType, + styles: Vec<&'a str>, + description: &str, + severity: ValidationSeverity, + ) -> Self { + Self { + styles, + conflict_type, + description: description.to_string(), + severity, + } + } + + /// Create duplicate name conflict + #[must_use] + pub fn duplicate_name(style_names: Vec<&'a str>) -> Self { + let description = format!("Duplicate style names found: {style_names:?}"); + Self::new( + ConflictType::DuplicateName, + style_names, + &description, + ValidationSeverity::Error, + ) + } + + /// Create circular inheritance conflict + #[must_use] + pub fn circular_inheritance(cycle_styles: Vec<&'a str>) -> Self { + let description = format!("Circular inheritance detected: {cycle_styles:?}"); + Self::new( + ConflictType::CircularInheritance, + cycle_styles, + &description, + ValidationSeverity::Error, + ) + } + + /// Create missing reference conflict + #[must_use] + pub fn missing_reference(referencing_style: &'a str, missing_style: &'a str) -> Self { + let description = + format!("Style '{referencing_style}' references non-existent style '{missing_style}'"); + Self::new( + ConflictType::MissingReference, + vec![referencing_style], + &description, + ValidationSeverity::Error, + ) + } + + /// Create missing parent conflict + #[must_use] + pub fn missing_parent(style_name: &'a str, parent_name: &'a str) -> Self { + let description = + format!("Style '{style_name}' inherits from non-existent parent '{parent_name}'"); + Self::new( + ConflictType::MissingReference, + vec![style_name], + &description, + ValidationSeverity::Warning, + ) + } +} + +impl fmt::Display for ConflictType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateName => write!(f, "duplicate_name"), + Self::CircularInheritance => write!(f, "circular_inheritance"), + Self::PropertyConflict => write!(f, "property_conflict"), + Self::MissingReference => write!(f, "missing_reference"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_issue_creation() { + let issue = StyleValidationIssue::error("font_size", "Invalid font size value"); + assert_eq!(issue.severity, ValidationSeverity::Error); + assert_eq!(issue.field, "font_size"); + assert!(issue.suggestion.is_none()); + + let info = StyleValidationIssue::info_with_suggestion( + "font_name", + "Font may not be available", + "Use Arial as fallback", + ); + assert_eq!(info.severity, ValidationSeverity::Info); + assert!(info.suggestion.is_some()); + } + + #[test] + fn inheritance_tracking() { + let mut inheritance = StyleInheritance::new("Child"); + assert!(inheritance.is_root()); + assert!(inheritance.is_leaf()); + + inheritance.add_parent("Parent"); + assert!(!inheritance.is_root()); + assert!(inheritance.has_inheritance()); + + inheritance.add_child("Grandchild"); + assert!(!inheritance.is_leaf()); + } + + #[test] + fn conflict_creation() { + let conflict = StyleConflict::duplicate_name(vec!["Style1", "Style1"]); + assert_eq!(conflict.conflict_type, ConflictType::DuplicateName); + assert_eq!(conflict.severity, ValidationSeverity::Error); + + let missing = StyleConflict::missing_reference("Child", "MissingParent"); + assert_eq!(missing.conflict_type, ConflictType::MissingReference); + assert!(missing.description.contains("MissingParent")); + } + + #[test] + fn severity_ordering() { + assert!(ValidationSeverity::Error > ValidationSeverity::Warning); + assert!(ValidationSeverity::Warning > ValidationSeverity::Info); + } +} diff --git a/crates/ass-core/lib.rs b/crates/ass-core/lib.rs new file mode 100644 index 0000000..89a8120 --- /dev/null +++ b/crates/ass-core/lib.rs @@ -0,0 +1,332 @@ +//! # ASS-RS Core +//! +//! High-performance, memory-efficient ASS (Advanced `SubStation` Alpha) subtitle format parser, +//! analyzer, and manipulator. Surpasses libass in modularity, reusability, and efficiency +//! through zero-copy parsing, trait-based extensibility, and strict memory management. +//! +//! ## Features +//! +//! - **Zero-copy parsing**: Uses `&str` spans to avoid allocations +//! - **Incremental updates**: Partial re-parsing for <2ms edits +//! - **SIMD optimization**: Feature-gated performance improvements +//! - **Extensible plugins**: Runtime tag and section handlers +//! - **Strict compliance**: Full ASS v4+, SSA v4, and libass 0.17.4+ support +//! - **Thread-safe**: Immutable `Script` design with `Send + Sync` +//! +//! ## Quick Start +//! +//! ```rust +//! use ass_core::{Script, analysis::ScriptAnalysis}; +//! +//! let script_text = r#" +//! [Script Info] +//! Title: Example +//! ScriptType: v4.00+ +//! +//! [V4+ Styles] +//! Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +//! Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1 +//! +//! [Events] +//! Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +//! Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,Hello World! +//! "#; +//! +//! let script = Script::parse(script_text)?; +//! let analysis = ScriptAnalysis::analyze(&script)?; +//! # Ok::<(), Box>(()) +//! ``` +//! +//! ## Performance Targets +//! +//! - Parse: <5ms for 1KB scripts +//! - Analysis: <2ms for typical content +//! - Memory: ~1.1x input size via zero-copy spans +//! - Incremental: <2ms for single-event updates + +#![cfg_attr(not(feature = "std"), no_std)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![deny(clippy::all)] +#![deny(unsafe_code)] +#![allow(clippy::negative_feature_names)] + +// Always make alloc available, whether in std or no_std mode +extern crate alloc; + +pub mod parser; +pub mod tokenizer; + +#[cfg(feature = "analysis")] +#[cfg_attr(docsrs, doc(cfg(feature = "analysis")))] +pub mod analysis; + +#[cfg(feature = "plugins")] +#[cfg_attr(docsrs, doc(cfg(feature = "plugins")))] +pub mod plugin; + +pub mod utils; + +pub use parser::{ParseError, Script, Section}; +pub use tokenizer::{AssTokenizer, Token}; + +#[cfg(feature = "analysis")] +pub use analysis::ScriptAnalysis; + +#[cfg(feature = "plugins")] +pub use plugin::ExtensionRegistry; + +pub use utils::{CoreError, Spans}; + +/// Crate version for runtime compatibility checks +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Supported ASS script versions for compatibility and feature detection. +/// +/// ASS scripts can declare different versions that affect parsing behavior +/// and available features. This enum helps determine which parsing mode +/// to use and which features are available. +/// +/// # Examples +/// +/// ```rust +/// use ass_core::ScriptVersion; +/// +/// // Parse from header +/// let version = ScriptVersion::from_header("v4.00+").unwrap(); +/// assert_eq!(version, ScriptVersion::AssV4); +/// +/// // Check feature support +/// assert!(!ScriptVersion::SsaV4.supports_extensions()); +/// assert!(ScriptVersion::AssV4Plus.supports_extensions()); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScriptVersion { + /// SSA v4.00 (`SubStation` Alpha legacy format). + /// + /// Provides compatibility with legacy SSA files. Limited feature set + /// compared to modern ASS versions. + SsaV4, + /// ASS v4.00+ (Advanced `SubStation` Alpha standard). + /// + /// The most common format used by modern subtitle tools. Supports + /// all standard ASS features and tags. + AssV4, + /// ASS v4.00+ with extensions (libass 0.17.4+ compatibility). + /// + /// Extended format supporting newer features like `\kt` karaoke tags, + /// Unicode line wrapping, and other libass extensions. + AssV4Plus, +} + +impl ScriptVersion { + /// Parse script version from a `ScriptType` header value. + /// + /// Converts header strings commonly found in `[Script Info]` sections + /// to the appropriate script version enum. Handles various formats + /// including extended versions. + /// + /// # Arguments + /// + /// * `header` - The header value string (usually from `ScriptType` field) + /// + /// # Returns + /// + /// Returns `Some(ScriptVersion)` if the header is recognized, or `None` + /// if the version string is invalid or unsupported. + /// + /// # Examples + /// + /// ```rust + /// use ass_core::ScriptVersion; + /// + /// assert_eq!(ScriptVersion::from_header("v4.00"), Some(ScriptVersion::SsaV4)); + /// assert_eq!(ScriptVersion::from_header("v4.00+"), Some(ScriptVersion::AssV4)); + /// assert_eq!(ScriptVersion::from_header("v4.00++"), Some(ScriptVersion::AssV4Plus)); + /// assert_eq!(ScriptVersion::from_header("invalid"), None); + /// ``` + #[must_use] + pub fn from_header(header: &str) -> Option { + match header.trim() { + "v4.00" => Some(Self::SsaV4), + "v4.00+" => Some(Self::AssV4), + "v4.00++" | "v4.00+ extended" => Some(Self::AssV4Plus), + _ => None, + } + } + + /// Check if the script version supports modern ASS extensions. + /// + /// Modern ASS extensions include features like: + /// - `\kt` karaoke timing tags + /// - Unicode line wrapping + /// - Extended color formats + /// - Advanced animation features + /// + /// Only `AssV4Plus` currently supports these extensions, as they + /// require libass 0.17.4+ compatibility. + /// + /// # Returns + /// + /// Returns `true` if the version supports extensions, `false` otherwise. + /// + /// # Examples + /// + /// ```rust + /// use ass_core::ScriptVersion; + /// + /// assert!(!ScriptVersion::SsaV4.supports_extensions()); + /// assert!(!ScriptVersion::AssV4.supports_extensions()); + /// assert!(ScriptVersion::AssV4Plus.supports_extensions()); + /// ``` + #[must_use] + pub const fn supports_extensions(self) -> bool { + matches!(self, Self::AssV4Plus) + } +} + +/// Result type for core operations, using the crate's unified `CoreError`. +/// +/// This type alias provides a convenient way to return results from core operations +/// without having to specify the error type explicitly in every function signature. +/// +/// # Examples +/// +/// ```rust +/// use ass_core::{Result, Script}; +/// +/// fn parse_script_safely(input: &str) -> Result + + +
+
+

ASS Renderer Compatibility Report

+

Pixel-perfect comparison with libass reference implementation

+
+"# + .to_string() + } + + /// Generate summary section + fn generate_summary_section(&self) -> Result { + let total_tests = self.results.len(); + let passed_tests = self.results.iter().filter(|r| r.passed).count(); + let failed_tests = total_tests - passed_tests; + + let avg_diff = if total_tests > 0 { + self.results + .iter() + .map(|r| r.pixel_diff_percentage) + .sum::() + / total_tests as f64 + } else { + 0.0 + }; + + let max_diff = self + .results + .iter() + .map(|r| r.pixel_diff_percentage) + .fold(0.0, f64::max); + + let avg_performance = if self.config.include_performance { + let performance_results: Vec = self + .results + .iter() + .filter_map(|r| r.performance_ratio) + .collect(); + + if !performance_results.is_empty() { + Some(performance_results.iter().sum::() / performance_results.len() as f64) + } else { + None + } + } else { + None + }; + + let mut html = String::new(); + html.push_str( + r#"
+

Test Summary

+
+
"#, + ); + + html.push_str(&format!( + r#" +

Total Tests

+
{}
+
+
+

Passed

+
{}
+
({:.1}%)
+
+
+

Failed

+
{}
+
({:.1}%)
+
"#, + total_tests, + passed_tests, + (passed_tests as f64 / total_tests as f64) * 100.0, + failed_tests, + (failed_tests as f64 / total_tests as f64) * 100.0 + )); + + html.push_str(&format!( + r#" +
+

Average Difference

+
{:.3}%
+
+
+

Maximum Difference

+
{:.3}%
+
"#, + avg_diff * 100.0, + max_diff * 100.0 + )); + + if let Some(perf) = avg_performance { + html.push_str(&format!( + r#" +
+

Performance Ratio

+
{perf:.2}x
+
vs libass
+
"# + )); + } + + html.push_str( + r#" +
+
"#, + ); + + Ok(html) + } + + /// Generate filter controls + fn generate_filter_controls() -> String { + r#"
+

Filters

+
+ + + + + +
+
"# + .to_string() + } + + /// Generate categorized results + fn generate_categorized_results(&self) -> Result { + let mut categories: HashMap> = HashMap::new(); + + // Categorize results by test name patterns + for result in &self.results { + let category = Self::categorize_test(&result.test_name); + categories.entry(category).or_default().push(result); + } + + let mut html = String::new(); + html.push_str( + r#"
+

Test Results by Category

"#, + ); + + for (category, results) in categories { + html.push_str(&format!( + r#" +
+

{category}

+
"# + )); + + for result in results { + html.push_str(&self.generate_result_card(result)?); + } + + html.push_str( + r#" +
+
"#, + ); + } + + html.push_str(r#"
"#); + Ok(html) + } + + /// Generate flat results list + fn generate_flat_results(&self) -> Result { + let mut html = String::new(); + html.push_str( + r#"
+

All Test Results

+
"#, + ); + + for result in &self.results { + html.push_str(&self.generate_result_card(result)?); + } + + html.push_str( + r#" +
+
"#, + ); + Ok(html) + } + + /// Generate individual result card + fn generate_result_card(&self, result: &CompatibilityResult) -> Result { + let status_class = if result.passed { "passed" } else { "failed" }; + let diff_percent = result.pixel_diff_percentage * 100.0; + + let mut html = format!( + r#" +
+

{}

+
+ {} + {:.3}% diff +
"#, + status_class, + if result.passed { "passed" } else { "failed" }, + diff_percent, + result.test_name, + if result.passed { "PASS" } else { "FAIL" }, + diff_percent + ); + + // Add metrics + if self.config.include_metrics { + html.push_str(&format!( + r#" +
+
+ Max Pixel Diff: + {} +
"#, + result.max_pixel_diff + )); + + if let Some(perf) = result.performance_ratio { + html.push_str(&format!( + r#" +
+ Performance: + {perf:.2}x +
"# + )); + } + + if !result.diff_regions.is_empty() { + html.push_str(&format!( + r#" +
+ Diff Regions: + {} +
"#, + result.diff_regions.len() + )); + } + + html.push_str(r#"
"#); + } + + // Add thumbnails if enabled and files exist + if self.config.include_thumbnails { + html.push_str(&self.generate_thumbnail_section(&result.test_name)?); + } + + // Add diff regions details + if !result.diff_regions.is_empty() { + html.push_str(&self.generate_diff_regions_section(&result.diff_regions)?); + } + + html.push_str(r#"
"#); + Ok(html) + } + + /// Generate thumbnail section for a test + #[cfg(not(feature = "nostd"))] + fn generate_thumbnail_section(&self, test_name: &str) -> Result { + let mut html = String::new(); + + // Check if thumbnail files exist + let our_path = Path::new(&self.output_dir).join(format!("{test_name}_ours.png")); + let libass_path = Path::new(&self.output_dir).join(format!("{test_name}_libass.png")); + let diff_path = Path::new(&self.output_dir).join(format!("{test_name}_diff.png")); + + if our_path.exists() || libass_path.exists() || diff_path.exists() { + html.push_str( + r#" +
+
"#, + ); + + if our_path.exists() { + html.push_str(&format!( + r#" +
+ Our Renderer + +
"# + )); + } + + if libass_path.exists() { + html.push_str(&format!(r#" +
+ libass Reference + +
"#)); + } + + if diff_path.exists() { + html.push_str(&format!( + r#" +
+ Difference Map + +
"# + )); + } + + html.push_str( + r#" +
+
"#, + ); + } + + Ok(html) + } + + #[cfg(feature = "nostd")] + fn generate_thumbnail_section(&self, _test_name: &str) -> Result { + Ok(String::new()) + } + + /// Generate diff regions section + fn generate_diff_regions_section(&self, regions: &[DiffRegion]) -> Result { + if regions.is_empty() { + return Ok(String::new()); + } + + let mut html = String::new(); + html.push_str( + r#" +
+
Difference Regions
+
"#, + ); + + for (i, region) in regions.iter().enumerate().take(10) { + // Limit to first 10 regions + let region_type = match region.diff_type { + DiffType::PixelValue => "pixel", + DiffType::TextPosition => "position", + DiffType::AnimationTiming => "timing", + DiffType::FontRendering => "font", + DiffType::Effects => "effects", + DiffType::Transform => "transform", + }; + + html.push_str(&format!( + r#" +
+ {}x{} at ({}, {}) + {} + avg: {:.1}, max: {} +
"#, + if i % 2 == 0 { "even" } else { "odd" }, + region_type, + region.bounds.2, + region.bounds.3, + region.bounds.0, + region.bounds.1, + region_type, + region.avg_diff, + region.max_diff + )); + } + + if regions.len() > 10 { + html.push_str(&format!( + r#" +
+ ... and {} more regions +
"#, + regions.len() - 10 + )); + } + + html.push_str( + r#" +
+
"#, + ); + + Ok(html) + } + + /// Generate CSS styles + fn generate_css() -> String { + r#"/* CSS styles for compatibility report */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + margin: 0; + padding: 20px; + background-color: #f5f5f5; + color: #333; +} + +.container { + max-width: 1200px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + overflow: hidden; +} + +header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 30px; + text-align: center; +} + +header h1 { + margin: 0; + font-size: 2.5em; + font-weight: 300; +} + +.subtitle { + margin: 10px 0 0 0; + opacity: 0.9; + font-size: 1.1em; +} + +.summary { + padding: 30px; + border-bottom: 1px solid #eee; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.summary-card { + background: #f8f9fa; + padding: 20px; + border-radius: 6px; + text-align: center; + border-left: 4px solid #007bff; +} + +.summary-card.passed { + border-left-color: #28a745; + background: #f8fff9; +} + +.summary-card.failed { + border-left-color: #dc3545; + background: #fff8f8; +} + +.summary-card h3 { + margin: 0 0 10px 0; + font-size: 0.9em; + text-transform: uppercase; + color: #666; + font-weight: 600; +} + +.metric-value { + font-size: 2em; + font-weight: bold; + color: #333; +} + +.metric-percent { + font-size: 0.9em; + color: #666; + margin-top: 5px; +} + +.metric-help { + font-size: 0.8em; + color: #999; + margin-top: 5px; +} + +.filters { + padding: 20px 30px; + background: #f8f9fa; + border-bottom: 1px solid #eee; +} + +.filter-controls { + display: flex; + gap: 20px; + align-items: center; + flex-wrap: wrap; +} + +.filter-controls label { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.9em; +} + +button { + background: #007bff; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; +} + +button:hover { + background: #0056b3; +} + +.results { + padding: 30px; +} + +.category { + margin-bottom: 40px; +} + +.category-title { + color: #495057; + border-bottom: 2px solid #007bff; + padding-bottom: 10px; + margin-bottom: 20px; +} + +.results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); + gap: 20px; +} + +.result-card { + background: white; + border: 1px solid #dee2e6; + border-radius: 6px; + padding: 20px; + transition: all 0.2s ease; +} + +.result-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + transform: translateY(-2px); +} + +.result-card.passed { + border-left: 4px solid #28a745; +} + +.result-card.failed { + border-left: 4px solid #dc3545; +} + +.test-name { + margin: 0 0 10px 0; + font-size: 1.1em; + color: #333; +} + +.test-status { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.status-badge { + padding: 4px 8px; + border-radius: 4px; + font-size: 0.8em; + font-weight: bold; +} + +.passed .status-badge { + background: #d4edda; + color: #155724; +} + +.failed .status-badge { + background: #f8d7da; + color: #721c24; +} + +.diff-value { + font-size: 0.9em; + color: #666; +} + +.metrics { + margin-bottom: 15px; +} + +.metric { + display: flex; + justify-content: space-between; + margin-bottom: 5px; + font-size: 0.9em; +} + +.metric .label { + color: #666; +} + +.metric .value { + font-weight: 500; +} + +.thumbnails { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #eee; +} + +.thumbnail-row { + display: flex; + gap: 10px; + justify-content: space-around; +} + +.thumbnail { + text-align: center; + flex: 1; +} + +.thumbnail img { + max-width: 100%; + max-height: 120px; + border: 1px solid #ddd; + border-radius: 4px; + cursor: pointer; +} + +.thumbnail img:hover { + border-color: #007bff; +} + +.thumbnail label { + display: block; + margin-top: 5px; + font-size: 0.8em; + color: #666; +} + +.diff-regions { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid #eee; +} + +.diff-regions h5 { + margin: 0 0 10px 0; + font-size: 0.9em; + color: #666; + text-transform: uppercase; +} + +.regions-list { + max-height: 150px; + overflow-y: auto; +} + +.region-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 0; + font-size: 0.8em; + border-bottom: 1px solid #f1f1f1; +} + +.region-item.even { + background: #f8f9fa; +} + +.region-bounds { + color: #333; + font-family: monospace; +} + +.region-type { + padding: 2px 6px; + border-radius: 3px; + background: #e9ecef; + color: #495057; +} + +.region-type.type-pixel { background: #fff3cd; color: #856404; } +.region-type.type-position { background: #d4edda; color: #155724; } +.region-type.type-timing { background: #cce7ff; color: #004085; } +.region-type.type-font { background: #f8d7da; color: #721c24; } +.region-type.type-effects { background: #e2e3e5; color: #383d41; } +.region-type.type-transform { background: #ffeeba; color: #533f03; } + +.region-diff { + color: #666; + font-family: monospace; +} + +.hidden { + display: none !important; +} + +/* Responsive design */ +@media (max-width: 768px) { + .container { + margin: 10px; + border-radius: 0; + } + + .results-grid { + grid-template-columns: 1fr; + } + + .filter-controls { + flex-direction: column; + align-items: flex-start; + } + + .summary-grid { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + } +}"# + .to_string() + } + + /// Generate JavaScript for interactive features + fn generate_javascript() -> String { + r#"// JavaScript for interactive compatibility report +document.addEventListener('DOMContentLoaded', function() { + // Filter controls + const showPassed = document.getElementById('show-passed'); + const showFailed = document.getElementById('show-failed'); + const statusFilter = document.getElementById('status-filter'); + const diffThreshold = document.getElementById('diff-threshold'); + const diffValue = document.getElementById('diff-value'); + const resetFilters = document.getElementById('reset-filters'); + + // Update diff threshold display + if (diffThreshold && diffValue) { + diffThreshold.addEventListener('input', function() { + diffValue.textContent = this.value + '%'; + applyFilters(); + }); + } + + // Apply filters + function applyFilters() { + const cards = document.querySelectorAll('.result-card'); + const showPassedChecked = showPassed ? showPassed.checked : true; + const showFailedChecked = showFailed ? showFailed.checked : true; + const statusFilterValue = statusFilter ? statusFilter.value : 'all'; + const maxDiff = diffThreshold ? parseFloat(diffThreshold.value) : 100; + + cards.forEach(card => { + const status = card.dataset.status; + const diff = parseFloat(card.dataset.diff) || 0; + + let visible = true; + + // Status filter + if (statusFilterValue === 'passed' && status !== 'passed') { + visible = false; + } else if (statusFilterValue === 'failed' && status !== 'failed') { + visible = false; + } + + // Checkbox filters + if (status === 'passed' && !showPassedChecked) { + visible = false; + } else if (status === 'failed' && !showFailedChecked) { + visible = false; + } + + // Difference threshold + if (diff > maxDiff) { + visible = false; + } + + card.classList.toggle('hidden', !visible); + }); + + updateResultsCount(); + } + + // Update visible results count + function updateResultsCount() { + const visibleCards = document.querySelectorAll('.result-card:not(.hidden)'); + const totalCards = document.querySelectorAll('.result-card'); + + // Update summary if exists + const summaryElement = document.querySelector('.results h2'); + if (summaryElement) { + summaryElement.textContent = `Test Results (${visibleCards.length} of ${totalCards.length} shown)`; + } + } + + // Event listeners + if (showPassed) { + showPassed.addEventListener('change', applyFilters); + } + if (showFailed) { + showFailed.addEventListener('change', applyFilters); + } + if (statusFilter) { + statusFilter.addEventListener('change', applyFilters); + } + if (resetFilters) { + resetFilters.addEventListener('click', function() { + if (showPassed) showPassed.checked = true; + if (showFailed) showFailed.checked = true; + if (statusFilter) statusFilter.value = 'all'; + if (diffThreshold) { + diffThreshold.value = '10'; + diffValue.textContent = '10.0%'; + } + applyFilters(); + }); + } + + // Image modal functionality + const images = document.querySelectorAll('.thumbnail img'); + images.forEach(img => { + img.addEventListener('click', function() { + showImageModal(this.src, this.alt); + }); + }); + + function showImageModal(src, alt) { + // Create modal + const modal = document.createElement('div'); + modal.className = 'image-modal'; + modal.innerHTML = ` + + `; + + // Add modal styles + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + `; + + const content = modal.querySelector('.modal-content'); + content.style.cssText = ` + position: relative; + max-width: 90%; + max-height: 90%; + background: white; + border-radius: 8px; + padding: 20px; + text-align: center; + `; + + const image = modal.querySelector('img'); + image.style.cssText = ` + max-width: 100%; + max-height: 70vh; + border-radius: 4px; + `; + + const closeBtn = modal.querySelector('.modal-close'); + closeBtn.style.cssText = ` + position: absolute; + top: 10px; + right: 15px; + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #666; + `; + + // Close modal handlers + closeBtn.addEventListener('click', () => modal.remove()); + modal.addEventListener('click', (e) => { + if (e.target === modal || e.target.className === 'modal-backdrop') { + modal.remove(); + } + }); + + document.addEventListener('keydown', function escHandler(e) { + if (e.key === 'Escape') { + modal.remove(); + document.removeEventListener('keydown', escHandler); + } + }); + + document.body.appendChild(modal); + } + + // Initialize filters + applyFilters(); + + // Sort functionality + addSortControls(); + + function addSortControls() { + const resultsSection = document.querySelector('.results'); + if (!resultsSection) return; + + const sortControls = document.createElement('div'); + sortControls.className = 'sort-controls'; + sortControls.innerHTML = ` + + + `; + + sortControls.style.cssText = ` + margin-bottom: 20px; + padding: 15px; + background: #f8f9fa; + border-radius: 6px; + display: flex; + gap: 20px; + align-items: center; + `; + + const h2 = resultsSection.querySelector('h2'); + if (h2) { + h2.insertAdjacentElement('afterend', sortControls); + } + + const sortBy = document.getElementById('sort-by'); + const sortReverse = document.getElementById('sort-reverse'); + + function applySorting() { + const grids = document.querySelectorAll('.results-grid'); + grids.forEach(grid => { + const cards = Array.from(grid.querySelectorAll('.result-card')); + + cards.sort((a, b) => { + let aValue, bValue; + + switch (sortBy.value) { + case 'name': + aValue = a.querySelector('.test-name').textContent; + bValue = b.querySelector('.test-name').textContent; + break; + case 'status': + aValue = a.dataset.status; + bValue = b.dataset.status; + break; + case 'diff': + aValue = parseFloat(a.dataset.diff) || 0; + bValue = parseFloat(b.dataset.diff) || 0; + break; + case 'performance': + aValue = parseFloat(a.querySelector('.metric .value')?.textContent) || 0; + bValue = parseFloat(b.querySelector('.metric .value')?.textContent) || 0; + break; + default: + return 0; + } + + let result; + if (typeof aValue === 'string') { + result = aValue.localeCompare(bValue); + } else { + result = aValue - bValue; + } + + return sortReverse.checked ? -result : result; + }); + + // Re-append sorted cards + cards.forEach(card => grid.appendChild(card)); + }); + } + + sortBy.addEventListener('change', applySorting); + sortReverse.addEventListener('change', applySorting); + } +}); +"#.to_string() + } + + /// Generate HTML footer + fn generate_html_footer() -> String { + { + #[cfg(not(feature = "nostd"))] + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format!("{} seconds since epoch", d.as_secs())) + .unwrap_or_else(|_| "unknown time".to_string()); + #[cfg(feature = "nostd")] + let timestamp = "unknown time".to_string(); + + format!( + r#" +
+

Report generated on {timestamp} by ASS Renderer Compatibility Testing Framework

+

Compare with libass reference implementation for pixel-perfect subtitle rendering

+
+
+ +"# + ) + } + } + + /// Generate summary JSON for programmatic access + #[cfg(feature = "serde")] + fn generate_summary_json(&self) -> Result { + let total_tests = self.results.len(); + let passed_tests = self.results.iter().filter(|r| r.passed).count(); + + let summary = serde_json::json!({ + "summary": { + "total_tests": total_tests, + "passed_tests": passed_tests, + "failed_tests": total_tests - passed_tests, + "pass_rate": if total_tests > 0 { passed_tests as f64 / total_tests as f64 } else { 0.0 }, + "average_difference": if total_tests > 0 { + self.results.iter().map(|r| r.pixel_diff_percentage).sum::() / total_tests as f64 + } else { 0.0 }, + "maximum_difference": self.results.iter().map(|r| r.pixel_diff_percentage).fold(0.0, f64::max) + }, + "results": self.results.iter().map(|r| { + serde_json::json!({ + "test_name": r.test_name, + "passed": r.passed, + "pixel_diff_percentage": r.pixel_diff_percentage, + "max_pixel_diff": r.max_pixel_diff, + "performance_ratio": r.performance_ratio, + "diff_regions_count": r.diff_regions.len() + }) + }).collect::>() + }); + + serde_json::to_string_pretty(&summary) + .map_err(|e| RenderError::BackendError(format!("Failed to serialize summary: {e}"))) + } + + /// Categorize test by name pattern + fn categorize_test(test_name: &str) -> String { + if test_name.contains("basic") || test_name.contains("text") { + "Basic Text Rendering".to_string() + } else if test_name.contains("animation") + || test_name.contains("move") + || test_name.contains("fade") + { + "Animations".to_string() + } else if test_name.contains("transform") + || test_name.contains("rotation") + || test_name.contains("scale") + { + "Transformations".to_string() + } else if test_name.contains("karaoke") { + "Karaoke Effects".to_string() + } else if test_name.contains("drawing") || test_name.contains("clip") { + "Drawing Commands".to_string() + } else if test_name.contains("effect") + || test_name.contains("blur") + || test_name.contains("shadow") + { + "Visual Effects".to_string() + } else if test_name.contains("edge") || test_name.contains("error") { + "Edge Cases".to_string() + } else { + "Other".to_string() + } + } +} + +/// Quick function to generate a basic report from results +#[cfg(not(feature = "nostd"))] +pub fn generate_compatibility_report( + results: Vec, + output_dir: &str, +) -> Result { + let config = ReportConfig::default(); + let mut generator = VisualReportGenerator::new(output_dir.to_string(), config); + generator.add_results(results); + generator.generate_report() +} diff --git a/crates/ass-renderer/src/layout/mod.rs b/crates/ass-renderer/src/layout/mod.rs new file mode 100644 index 0000000..0f3bb13 --- /dev/null +++ b/crates/ass-renderer/src/layout/mod.rs @@ -0,0 +1,281 @@ +//! Layout system for proper subtitle positioning + +pub mod positioning; + +#[cfg(feature = "nostd")] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; +#[cfg(not(feature = "nostd"))] +use std::{string::String, vec::Vec}; + +pub use positioning::{convert_ssa_alignment, scale_coordinates, BoundingBox, PositionInfo}; + +/// Alignment types for subtitle positioning +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Alignment { + // ASS alignments (numpad layout) + /// Bottom left alignment + BottomLeft = 1, + /// Bottom center alignment + BottomCenter = 2, + /// Bottom right alignment + BottomRight = 3, + /// Middle left alignment + MiddleLeft = 4, + /// Center alignment + Center = 5, + /// Middle right alignment + MiddleRight = 6, + /// Top left alignment + TopLeft = 7, + /// Top center alignment + TopCenter = 8, + /// Top right alignment + TopRight = 9, +} + +impl Alignment { + /// Convert from numeric alignment value + pub fn from_value(value: u8) -> Self { + match value { + 1 => Self::BottomLeft, + 2 => Self::BottomCenter, + 3 => Self::BottomRight, + 4 => Self::MiddleLeft, + 5 => Self::Center, + 6 => Self::MiddleRight, + 7 => Self::TopLeft, + 8 => Self::TopCenter, + 9 => Self::TopRight, + _ => Self::BottomCenter, // Default + } + } + + /// Check if this is a legacy SSA alignment value + pub fn from_ssa_legacy(value: u8) -> Self { + // SSA legacy: 1=left, 2=center, 3=right + // +0=sub(bottom), +4=title(middle), +8=top, +128=mid-title + match value & 3 { + 1 => { + // Left alignment + match value & 12 { + 0 => Self::BottomLeft, + 4 => Self::MiddleLeft, + 8 | 12 => Self::TopLeft, + _ => Self::BottomLeft, + } + } + 2 => { + // Center alignment + match value & 12 { + 0 => Self::BottomCenter, + 4 => Self::Center, + 8 | 12 => Self::TopCenter, + _ => Self::BottomCenter, + } + } + 3 => { + // Right alignment + match value & 12 { + 0 => Self::BottomRight, + 4 => Self::MiddleRight, + 8 | 12 => Self::TopRight, + _ => Self::BottomRight, + } + } + _ => Self::BottomCenter, + } + } +} + +/// Text metrics for layout calculations +#[derive(Debug, Clone)] +pub struct TextMetrics { + pub width: f32, + pub height: f32, + pub ascent: f32, + pub descent: f32, + pub line_gap: f32, + pub baseline: f32, +} + +impl TextMetrics { + /// Create from shaped text + pub fn from_shaped(shaped: &crate::pipeline::shaping::ShapedText) -> Self { + // ShapedText has width, height, and baseline + // We'll estimate ascent/descent from height and baseline + let ascent = shaped.baseline; + let descent = shaped.height - shaped.baseline; + + Self { + width: shaped.width, + height: shaped.height, + ascent, + descent, + line_gap: 0.0, // Not available in ShapedText + baseline: shaped.baseline, + } + } + + /// Create with estimated values + pub fn estimated(width: f32, height: f32) -> Self { + Self { + width, + height, + ascent: height * 0.8, + descent: height * 0.2, + line_gap: height * 0.1, + baseline: height * 0.8, + } + } +} + +/// Layout context for positioning calculations +pub struct LayoutContext { + pub screen_width: f32, + pub screen_height: f32, + pub margin_left: f32, + pub margin_right: f32, + pub margin_vertical: f32, + pub play_res_x: f32, + pub play_res_y: f32, +} + +impl LayoutContext { + pub fn new(screen_width: f32, screen_height: f32) -> Self { + Self { + screen_width, + screen_height, + margin_left: 0.0, + margin_right: 0.0, + margin_vertical: 0.0, + play_res_x: screen_width, + play_res_y: screen_height, + } + } + + /// Calculate position for text with given alignment and metrics + pub fn calculate_position(&self, alignment: Alignment, metrics: &TextMetrics) -> (f32, f32) { + // In ASS/SSA, the position is where the text glyph paths start + // For proper centering, we need to account for text dimensions + + // Calculate horizontal position (left edge of text bounding box) + let x = match alignment { + Alignment::BottomLeft | Alignment::MiddleLeft | Alignment::TopLeft => { + // Left alignment - text starts at left margin + self.margin_left + } + Alignment::BottomCenter | Alignment::Center | Alignment::TopCenter => { + // Center alignment - center the text horizontally + // This should put the left edge at center minus half width + (self.screen_width / 2.0) - (metrics.width / 2.0) + } + Alignment::BottomRight | Alignment::MiddleRight | Alignment::TopRight => { + // Right alignment - text ends at right margin + self.screen_width - self.margin_right - metrics.width + } + }; + + // Calculate vertical position (top edge of text bounding box) + // The glyphs are drawn from their top-left corner typically + let y = match alignment { + Alignment::TopLeft | Alignment::TopCenter | Alignment::TopRight => { + // Top alignment - text at top margin + self.margin_vertical + } + Alignment::MiddleLeft | Alignment::Center | Alignment::MiddleRight => { + // Middle alignment - center vertically + (self.screen_height / 2.0) - (metrics.height / 2.0) + } + Alignment::BottomLeft | Alignment::BottomCenter | Alignment::BottomRight => { + // Bottom alignment - text bottom at bottom margin + self.screen_height - self.margin_vertical - metrics.height + } + }; + + (x, y) + } + + /// Apply PlayResX/PlayResY scaling + pub fn apply_play_res_scaling(&self, x: f32, y: f32) -> (f32, f32) { + let scale_x = self.screen_width / self.play_res_x; + let scale_y = self.screen_height / self.play_res_y; + (x * scale_x, y * scale_y) + } +} + +/// Multi-line text layout +pub struct MultiLineLayout { + pub lines: Vec, + pub total_width: f32, + pub total_height: f32, +} + +/// Single line layout information +pub struct LineLayout { + pub text: String, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub baseline: f32, +} + +impl MultiLineLayout { + /// Create layout for multi-line text + pub fn new( + text: &str, + _context: &LayoutContext, + alignment: Alignment, + line_spacing: f32, + ) -> Self { + // Text has already been processed by ass-core, so \N is now \n + let lines: Vec<&str> = text.split('\n').collect(); + let mut line_layouts = Vec::new(); + let mut total_height = 0.0; + let mut max_width = 0.0; + + // TODO: Actual text shaping would go here + // For now, use estimates + let line_height = 50.0; // Placeholder + + for (i, line) in lines.iter().enumerate() { + let line_width = line.len() as f32 * 20.0; // Placeholder + max_width = f32::max(max_width, line_width); + + let y_offset = i as f32 * (line_height + line_spacing); + + line_layouts.push(LineLayout { + text: line.to_string(), + x: 0.0, // Will be adjusted based on alignment + y: y_offset, + width: line_width, + height: line_height, + baseline: line_height * 0.8, + }); + + total_height = y_offset + line_height; + } + + // Adjust line positions based on alignment + for line in &mut line_layouts { + line.x = match alignment { + Alignment::BottomLeft | Alignment::MiddleLeft | Alignment::TopLeft => 0.0, + Alignment::BottomCenter | Alignment::Center | Alignment::TopCenter => { + (max_width - line.width) / 2.0 + } + Alignment::BottomRight | Alignment::MiddleRight | Alignment::TopRight => { + max_width - line.width + } + }; + } + + Self { + lines: line_layouts, + total_width: max_width, + total_height, + } + } +} diff --git a/crates/ass-renderer/src/layout/positioning.rs b/crates/ass-renderer/src/layout/positioning.rs new file mode 100644 index 0000000..0ad1058 --- /dev/null +++ b/crates/ass-renderer/src/layout/positioning.rs @@ -0,0 +1,335 @@ +//! Advanced positioning and alignment calculations based on libass +//! +//! This module provides positioning calculations that match libass behavior, +//! including proper anchor point handling, rotation origins, and alignment. + +use crate::pipeline::tag_processor::ProcessedTags; + +/// Calculated position with anchor and origin points +#[derive(Debug, Clone)] +pub struct PositionInfo { + /// Actual render position (top-left of bounding box) + pub render_x: f32, + pub render_y: f32, + /// Anchor point for alignment (based on alignment value) + pub anchor_x: f32, + pub anchor_y: f32, + /// Rotation origin point + pub origin_x: f32, + pub origin_y: f32, + /// Whether position was explicitly set + pub explicit_position: bool, +} + +/// Bounding box for text +#[derive(Debug, Clone)] +pub struct BoundingBox { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// Configuration for position calculation +pub struct PositionConfig { + pub screen_width: f32, + pub screen_height: f32, + pub margin_left: f32, + pub margin_right: f32, + pub margin_vertical: f32, + pub default_alignment: u8, +} + +impl PositionInfo { + /// Calculate position based on alignment and tags + pub fn calculate(tags: &ProcessedTags, bbox: &BoundingBox, config: &PositionConfig) -> Self { + // Get effective alignment + let alignment = tags + .formatting + .alignment + .unwrap_or(config.default_alignment); + + // Calculate anchor point based on alignment + let (anchor_x, anchor_y) = Self::get_anchor_point( + alignment, + bbox, + config.screen_width, + config.screen_height, + config.margin_left, + config.margin_right, + config.margin_vertical, + ); + + // Check for explicit positioning + let (render_x, render_y, explicit) = if let Some((pos_x, pos_y)) = tags.position { + // Explicit \pos tag + let render_x = pos_x - Self::get_alignment_offset_x(alignment, bbox); + let render_y = pos_y - Self::get_alignment_offset_y(alignment, bbox); + (render_x, render_y, true) + } else if let Some((x1, y1, _x2, _y2, _t1, _t2)) = tags.movement { + // \move tag (simplified - should interpolate based on time) + // For now just use start position + let render_x = x1 - Self::get_alignment_offset_x(alignment, bbox); + let render_y = y1 - Self::get_alignment_offset_y(alignment, bbox); + (render_x, render_y, true) + } else { + // Use alignment-based positioning + (anchor_x, anchor_y, false) + }; + + // Calculate rotation origin + let (origin_x, origin_y) = if let Some((org_x, org_y)) = tags.origin { + // Explicit \org tag + (org_x, org_y) + } else { + // Default to anchor point (libass behavior) + (anchor_x + bbox.width / 2.0, anchor_y + bbox.height / 2.0) + }; + + Self { + render_x, + render_y, + anchor_x, + anchor_y, + origin_x, + origin_y, + explicit_position: explicit, + } + } + + /// Get anchor point based on alignment value + fn get_anchor_point( + alignment: u8, + bbox: &BoundingBox, + screen_width: f32, + screen_height: f32, + margin_left: f32, + margin_right: f32, + margin_vertical: f32, + ) -> (f32, f32) { + // Horizontal position based on alignment + let x = match alignment % 3 { + 1 => { + // Left alignment (1, 4, 7) + margin_left + } + 2 | 0 => { + // Center alignment (2, 5, 8) + (screen_width - bbox.width) / 2.0 + } + _ => { + // Right alignment (3, 6, 9) + screen_width - margin_right - bbox.width + } + }; + + // Vertical position based on alignment + let y = match alignment { + 1..=3 => { + // Bottom alignment + screen_height - margin_vertical - bbox.height + } + 4..=6 => { + // Middle alignment + (screen_height - bbox.height) / 2.0 + } + 7..=9 => { + // Top alignment + margin_vertical + } + _ => { + // Default to bottom + screen_height - margin_vertical - bbox.height + } + }; + + (x, y) + } + + /// Get horizontal offset for alignment anchor + fn get_alignment_offset_x(alignment: u8, bbox: &BoundingBox) -> f32 { + match alignment % 3 { + 1 => 0.0, // Left + 2 | 0 => bbox.width / 2.0, // Center + _ => bbox.width, // Right + } + } + + /// Get vertical offset for alignment anchor + fn get_alignment_offset_y(alignment: u8, bbox: &BoundingBox) -> f32 { + match alignment { + 1..=3 => bbox.height, // Bottom + 4..=6 => bbox.height / 2.0, // Middle + 7..=9 => 0.0, // Top + _ => bbox.height, // Default to bottom + } + } + + /// Calculate position with movement interpolation + pub fn calculate_with_movement( + tags: &ProcessedTags, + bbox: &BoundingBox, + config: &PositionConfig, + current_time_ms: u32, + event_start_ms: u32, + ) -> Self { + // Get base position + let mut pos = Self::calculate(tags, bbox, config); + + // Apply movement if present + if let Some((x1, y1, x2, y2, t1, t2)) = tags.movement { + let event_time = current_time_ms.saturating_sub(event_start_ms); + + // Calculate interpolation factor + let factor = if t2 > t1 { + let progress = (event_time.saturating_sub(t1)) as f32 / (t2 - t1) as f32; + progress.clamp(0.0, 1.0) + } else { + 1.0 // Instant movement + }; + + // Interpolate position + let current_x = x1 + (x2 - x1) * factor; + let current_y = y1 + (y2 - y1) * factor; + + // Apply alignment offset + let alignment = tags + .formatting + .alignment + .unwrap_or(config.default_alignment); + pos.render_x = current_x - Self::get_alignment_offset_x(alignment, bbox); + pos.render_y = current_y - Self::get_alignment_offset_y(alignment, bbox); + pos.explicit_position = true; + } + + pos + } +} + +/// Handle PlayResX/PlayResY coordinate scaling +pub fn scale_coordinates( + x: f32, + y: f32, + play_res_x: f32, + play_res_y: f32, + screen_width: f32, + screen_height: f32, +) -> (f32, f32) { + let scale_x = screen_width / play_res_x; + let scale_y = screen_height / play_res_y; + (x * scale_x, y * scale_y) +} + +/// Convert SSA legacy alignment to ASS alignment +pub fn convert_ssa_alignment(ssa_align: u8) -> u8 { + // SSA: 1=left, 2=center, 3=right + // +0=sub, +4=title, +8=top, +128=mid-title + let h_align = ssa_align & 3; + let v_align = (ssa_align >> 2) & 3; + + let h_part = match h_align { + 1 => 1, // Left + 2 => 2, // Center + 3 => 3, // Right + _ => 2, // Default center + }; + + let v_part = match v_align { + 0 => 0, // Bottom + 1 | 3 => 3, // Middle (title or mid-title) + 2 => 6, // Top + _ => 0, // Default bottom + }; + + h_part + v_part +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_alignment_positions() { + let bbox = BoundingBox { + x: 0.0, + y: 0.0, + width: 100.0, + height: 50.0, + }; + + let tags = ProcessedTags::default(); + + // Test bottom-center alignment (default) + let config = PositionConfig { + screen_width: 1920.0, + screen_height: 1080.0, + margin_left: 0.0, + margin_right: 0.0, + margin_vertical: 0.0, + default_alignment: 2, + }; + let pos = PositionInfo::calculate(&tags, &bbox, &config); + + assert_eq!(pos.anchor_x, 910.0); // (1920 - 100) / 2 + assert_eq!(pos.anchor_y, 1030.0); // 1080 - 0 - 50 + } + + #[test] + fn test_explicit_position() { + let bbox = BoundingBox { + x: 0.0, + y: 0.0, + width: 100.0, + height: 50.0, + }; + + let tags = ProcessedTags { + position: Some((500.0, 300.0)), + ..ProcessedTags::default() + }; + + let config = PositionConfig { + screen_width: 1920.0, + screen_height: 1080.0, + margin_left: 0.0, + margin_right: 0.0, + margin_vertical: 0.0, + default_alignment: 2, + }; + let pos = PositionInfo::calculate(&tags, &bbox, &config); + + assert!(pos.explicit_position); + assert_eq!(pos.render_x, 450.0); // 500 - 50 (center offset) + assert_eq!(pos.render_y, 250.0); // 300 - 50 (bottom offset) + } + + #[test] + fn test_movement_interpolation() { + let bbox = BoundingBox { + x: 0.0, + y: 0.0, + width: 100.0, + height: 50.0, + }; + + let tags = ProcessedTags { + movement: Some((100.0, 100.0, 500.0, 300.0, 0, 1000)), + ..ProcessedTags::default() + }; + + // Test at halfway point (500ms) + let config = PositionConfig { + screen_width: 1920.0, + screen_height: 1080.0, + margin_left: 0.0, + margin_right: 0.0, + margin_vertical: 0.0, + default_alignment: 2, + }; + let pos = PositionInfo::calculate_with_movement(&tags, &bbox, &config, 500, 0); + + // Position should be interpolated halfway + assert_eq!(pos.render_x, 250.0); // 300 - 50 (center offset) + assert_eq!(pos.render_y, 150.0); // 200 - 50 (bottom offset) + } +} diff --git a/crates/ass-renderer/src/lib.rs b/crates/ass-renderer/src/lib.rs new file mode 100644 index 0000000..52ffc1c --- /dev/null +++ b/crates/ass-renderer/src/lib.rs @@ -0,0 +1,50 @@ +//! ASS subtitle renderer with modular backend support +//! +//! `ass-renderer` provides high-performance subtitle rendering with support for +//! multiple backends including software (CPU), hardware (Vulkan/Metal), and web +//! (WebGPU/WebGL). + +#![cfg_attr(feature = "nostd", no_std)] +#![deny(unsafe_code)] // Changed from forbid to allow overrides in FFI modules +#![warn(missing_docs)] +#![allow(missing_docs)] // Allow missing docs for struct fields to reduce noise +#![allow(dead_code)] // Allow dead code for work-in-progress features +#![allow(unused_variables)] // Allow unused variables in development code + +#[cfg(feature = "nostd")] +extern crate alloc; + +pub mod animation; +pub mod backends; +pub mod cache; +pub mod collision; +#[cfg(not(feature = "nostd"))] +/// Debug and analysis tools for subtitle rendering +pub mod debug; +pub mod layout; +pub mod pipeline; +pub mod plugin; +pub mod renderer; +pub mod utils; + +pub use backends::{BackendType, RenderBackend}; +#[cfg(all(not(feature = "nostd"), feature = "libass-compare", not(target_arch = "wasm32")))] +pub use debug::LibassRenderer; +#[cfg(not(feature = "nostd"))] +pub use debug::{DebugPlayer, FrameAnalyzer, FrameInspector, PlayerFrame}; +pub use pipeline::{Pipeline, PipelineStage}; +pub use plugin::{EffectPlugin, PluginRegistry}; +pub use renderer::{Frame, RenderContext, Renderer}; +pub use utils::RenderError; + +#[cfg(feature = "backend-metrics")] +pub use backends::BackendMetrics; + +#[cfg(feature = "analysis-integration")] +pub use ass_core::analysis::styles::ResolvedStyle; +pub use ass_core::parser::ast::EventType; +/// Re-export commonly used types from ass-core +pub use ass_core::parser::{Event, Script, Section, Style}; + +/// Library version +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/ass-renderer/src/pipeline/animation.rs b/crates/ass-renderer/src/pipeline/animation.rs new file mode 100644 index 0000000..44cdd93 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/animation.rs @@ -0,0 +1,23 @@ +//! Animation processing for ASS effects + +/// Calculate progress for \move animation +pub fn calculate_move_progress(time_cs: u32, t1: u32, t2: u32) -> f32 { + if time_cs <= t1 { + 0.0 + } else if time_cs >= t2 { + 1.0 + } else { + (time_cs - t1) as f32 / (t2 - t1) as f32 + } +} + +/// Calculate progress for \fade animation +pub fn calculate_fade_progress(time_cs: u32, t1: u32, t2: u32) -> f32 { + if time_cs <= t1 { + 0.0 + } else if time_cs >= t2 { + 1.0 + } else { + (time_cs - t1) as f32 / (t2 - t1) as f32 + } +} diff --git a/crates/ass-renderer/src/pipeline/compositing/mod.rs b/crates/ass-renderer/src/pipeline/compositing/mod.rs new file mode 100644 index 0000000..6621c3d --- /dev/null +++ b/crates/ass-renderer/src/pipeline/compositing/mod.rs @@ -0,0 +1,85 @@ +//! Layer compositing module + +#[cfg(feature = "nostd")] +use alloc::{vec, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::vec::Vec; + +use crate::pipeline::IntermediateLayer; +use crate::utils::RenderError; + +/// Compositing mode +#[derive(Debug, Clone, Copy)] +pub enum CompositeMode { + /// Normal alpha blending + Normal, + /// Additive blending + Add, + /// Multiply blending + Multiply, + /// XOR blending for shapes + Xor, +} + +/// Composite multiple layers into a single buffer +pub fn composite_layers( + layers: &[IntermediateLayer], + width: u32, + height: u32, +) -> Result, RenderError> { + let mut output = vec![0u8; (width * height * 4) as usize]; + + for layer in layers { + composite_layer(layer, &mut output, width, height)?; + } + + Ok(output) +} + +/// Composite a single layer onto the output buffer +fn composite_layer( + layer: &IntermediateLayer, + output: &mut [u8], + width: u32, + height: u32, +) -> Result<(), RenderError> { + // TODO: Implement proper compositing + let _ = (layer, output, width, height); + Ok(()) +} + +/// Apply alpha blending using libass-compatible formula +/// +/// Uses integer arithmetic with specific rounding to match libass exactly: +/// - Multiply and add 255 for rounding bias +/// - Right shift by 8 instead of division by 255 +/// - This matches libass's MUL_BITMAPS/IMUL_BITMAPS operations +pub fn alpha_blend(src: [u8; 4], dst: [u8; 4]) -> [u8; 4] { + // Use integer arithmetic matching libass implementation + let src_alpha = src[3] as u32; + + if src_alpha == 0 { + // Fully transparent source, keep destination + return dst; + } + + if src_alpha == 255 && dst[3] == 0 { + // Opaque source over transparent destination + return src; + } + + // libass uses this formula: ((src * alpha + 255) >> 8) + // The +255 provides rounding bias to match libass exactly + let inv_src_alpha = 255 - src_alpha; + + // Blend colors using libass formula with rounding + let out_r = ((src[0] as u32 * src_alpha + dst[0] as u32 * inv_src_alpha + 255) >> 8) as u8; + let out_g = ((src[1] as u32 * src_alpha + dst[1] as u32 * inv_src_alpha + 255) >> 8) as u8; + let out_b = ((src[2] as u32 * src_alpha + dst[2] as u32 * inv_src_alpha + 255) >> 8) as u8; + + // Alpha calculation using same integer formula + let dst_alpha = dst[3] as u32; + let out_alpha = ((src_alpha * 255 + dst_alpha * inv_src_alpha + 255) >> 8) as u8; + + [out_r, out_g, out_b, out_alpha] +} diff --git a/crates/ass-renderer/src/pipeline/drawing/mod.rs b/crates/ass-renderer/src/pipeline/drawing/mod.rs new file mode 100644 index 0000000..ea0c873 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/drawing/mod.rs @@ -0,0 +1,379 @@ +//! Drawing command processing module + +#[cfg(feature = "nostd")] +use alloc::{ + format, + string::{String, ToString}, + vec::Vec, +}; +#[cfg(not(feature = "nostd"))] +use std::{ + string::{String, ToString}, + vec::Vec, +}; + +use crate::utils::RenderError; +use tiny_skia::{Path, PathBuilder}; + +/// A bezier curve represented as three control points: start, control, end +type BezierCurve = ((f32, f32), (f32, f32), (f32, f32)); + +/// Drawing command types +#[derive(Debug, Clone)] +pub enum DrawCommand { + /// Move to position (m command) + MoveTo { x: f32, y: f32 }, + /// Move without drawing (n command) + MoveToNoDraw { x: f32, y: f32 }, + /// Line to position (l command) + LineTo { x: f32, y: f32 }, + /// Cubic Bezier curve (b command) + BezierTo { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + x3: f32, + y3: f32, + }, + /// B-spline (s command - converted to bezier internally) + Spline { points: Vec<(f32, f32)> }, + /// Extended B-spline (p command) + ExtendSpline { points: Vec<(f32, f32)> }, + /// Close path (c command) + ClosePath, +} + +/// Process ASS drawing commands into a path +pub fn process_drawing_commands(commands: &str) -> Result, RenderError> { + // Try to parse the commands, return None for invalid input + let draw_commands = match parse_draw_commands(commands) { + Ok(commands) => commands, + Err(_) => return Ok(None), // Invalid drawing commands return None + }; + if draw_commands.is_empty() { + return Ok(None); + } + + let mut builder = PathBuilder::new(); + let mut _current_pos = (0.0, 0.0); + + for cmd in draw_commands { + match cmd { + DrawCommand::MoveTo { x, y } => { + builder.move_to(x, y); + _current_pos = (x, y); + } + DrawCommand::MoveToNoDraw { x, y } => { + builder.move_to(x, y); + _current_pos = (x, y); + } + DrawCommand::LineTo { x, y } => { + builder.line_to(x, y); + _current_pos = (x, y); + } + DrawCommand::BezierTo { + x1, + y1, + x2, + y2, + x3, + y3, + } => { + builder.cubic_to(x1, y1, x2, y2, x3, y3); + _current_pos = (x3, y3); + } + DrawCommand::Spline { ref points } => { + // Convert B-spline to Bezier curves + if points.len() >= 3 { + let beziers = spline_to_bezier(points, false); + for (c1, c2, end) in beziers { + builder.cubic_to(c1.0, c1.1, c2.0, c2.1, end.0, end.1); + _current_pos = end; + } + } + } + DrawCommand::ExtendSpline { ref points } => { + // Extended B-spline with additional control + if points.len() >= 3 { + let beziers = spline_to_bezier(points, true); + for (c1, c2, end) in beziers { + builder.cubic_to(c1.0, c1.1, c2.0, c2.1, end.0, end.1); + _current_pos = end; + } + } + } + DrawCommand::ClosePath => { + builder.close(); + } + } + } + + Ok(builder.finish()) +} + +/// Parse drawing commands from string +pub fn parse_draw_commands(text: &str) -> Result, RenderError> { + let mut commands = Vec::new(); + let tokens = tokenize_drawing_commands(text); + let mut i = 0; + + while i < tokens.len() { + match tokens[i].as_str() { + "m" => { + // Move to - can have multiple coordinate pairs + i += 1; + if i + 1 < tokens.len() { + // First move is always MoveTo + let x = parse_coord(&tokens[i])?; + let y = parse_coord(&tokens[i + 1])?; + commands.push(DrawCommand::MoveTo { x, y }); + i += 2; + + // Additional coordinate pairs become LineTo + while i + 1 < tokens.len() && !is_command(&tokens[i]) { + let x = parse_coord(&tokens[i])?; + let y = parse_coord(&tokens[i + 1])?; + commands.push(DrawCommand::LineTo { x, y }); + i += 2; + } + } else { + return Err(RenderError::InvalidDrawCommand( + "Incomplete move command".to_string(), + )); + } + } + "n" => { + // Move to without drawing - can have multiple coordinate pairs + i += 1; + while i + 1 < tokens.len() && !is_command(&tokens[i]) { + let x = parse_coord(&tokens[i])?; + let y = parse_coord(&tokens[i + 1])?; + commands.push(DrawCommand::MoveToNoDraw { x, y }); + i += 2; + } + + if commands.is_empty() + || !matches!(commands.last(), Some(DrawCommand::MoveToNoDraw { .. })) + { + return Err(RenderError::InvalidDrawCommand( + "Incomplete move command".to_string(), + )); + } + } + "l" => { + // Line to - can have multiple coordinate pairs + i += 1; + while i + 1 < tokens.len() && !is_command(&tokens[i]) { + let x = parse_coord(&tokens[i])?; + let y = parse_coord(&tokens[i + 1])?; + commands.push(DrawCommand::LineTo { x, y }); + i += 2; + } + + if commands.is_empty() + || !matches!(commands.last(), Some(DrawCommand::LineTo { .. })) + { + return Err(RenderError::InvalidDrawCommand( + "Incomplete line command".to_string(), + )); + } + } + "b" => { + // Bezier curve - can have multiple sets of 6 coordinates + i += 1; + while i + 5 < tokens.len() && !is_command(&tokens[i]) { + let x1 = parse_coord(&tokens[i])?; + let y1 = parse_coord(&tokens[i + 1])?; + let x2 = parse_coord(&tokens[i + 2])?; + let y2 = parse_coord(&tokens[i + 3])?; + let x3 = parse_coord(&tokens[i + 4])?; + let y3 = parse_coord(&tokens[i + 5])?; + commands.push(DrawCommand::BezierTo { + x1, + y1, + x2, + y2, + x3, + y3, + }); + i += 6; + } + + if commands.is_empty() + || !matches!(commands.last(), Some(DrawCommand::BezierTo { .. })) + { + return Err(RenderError::InvalidDrawCommand( + "Incomplete bezier command".to_string(), + )); + } + } + "s" => { + // B-spline (at least 3 points) + let mut points = Vec::new(); + i += 1; + + // Collect points until we hit another command or end + while i + 1 < tokens.len() { + if is_command(&tokens[i]) { + break; + } + let x = parse_coord(&tokens[i])?; + let y = parse_coord(&tokens[i + 1])?; + points.push((x, y)); + i += 2; + } + + if points.len() >= 3 { + // Close the spline by repeating first point if needed + let first = points[0]; + let last = points[points.len() - 1]; + if (first.0 - last.0).abs() > 0.01 || (first.1 - last.1).abs() > 0.01 { + points.push(first); + } + commands.push(DrawCommand::Spline { points }); + } else if !points.is_empty() { + return Err(RenderError::InvalidDrawCommand( + "B-spline needs at least 3 points".to_string(), + )); + } + } + "p" => { + // Extended B-spline + let mut points = Vec::new(); + i += 1; + + // Collect points for extended spline + while i + 1 < tokens.len() { + if is_command(&tokens[i]) { + break; + } + let x = parse_coord(&tokens[i])?; + let y = parse_coord(&tokens[i + 1])?; + points.push((x, y)); + i += 2; + } + + if points.len() >= 3 { + commands.push(DrawCommand::ExtendSpline { points }); + } else if !points.is_empty() { + return Err(RenderError::InvalidDrawCommand( + "Extended spline needs at least 3 points".to_string(), + )); + } + } + "c" => { + // Close path + commands.push(DrawCommand::ClosePath); + i += 1; + } + _ => { + // Unknown command or coordinate without command + i += 1; + } + } + } + + Ok(commands) +} + +/// Tokenize drawing command string +fn tokenize_drawing_commands(text: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + + for ch in text.chars() { + if ch.is_whitespace() { + if !current.is_empty() { + tokens.push(current.clone()); + current.clear(); + } + } else if ch.is_ascii_alphabetic() { + if !current.is_empty() { + tokens.push(current.clone()); + current.clear(); + } + tokens.push(ch.to_string().to_lowercase()); + } else { + current.push(ch); + } + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +/// Check if token is a command +fn is_command(token: &str) -> bool { + matches!(token, "m" | "n" | "l" | "b" | "s" | "p" | "c") +} + +/// Parse coordinate string to f32 +fn parse_coord(s: &str) -> Result { + s.parse::() + .map_err(|_| RenderError::InvalidDrawCommand(format!("Invalid coordinate: {s}"))) +} + +/// Convert B-spline control points to Bezier curves +/// +/// Parameters: +/// - points: Control points for the spline +/// - extended: Whether to use extended spline algorithm (for 'p' command) +fn spline_to_bezier(points: &[(f32, f32)], extended: bool) -> Vec { + let mut beziers = Vec::new(); + + if points.len() < 3 { + return beziers; + } + + if extended { + // Extended B-spline for 'p' command - uses Catmull-Rom interpolation + for i in 0..points.len() - 1 { + let p0 = if i > 0 { points[i - 1] } else { points[i] }; + let p1 = points[i]; + let p2 = points[i + 1]; + let p3 = if i + 2 < points.len() { + points[i + 2] + } else { + points[i + 1] + }; + + // Catmull-Rom to Bezier conversion + let tension = 0.5; // Standard Catmull-Rom tension + let c1 = ( + p1.0 + tension * (p2.0 - p0.0) / 3.0, + p1.1 + tension * (p2.1 - p0.1) / 3.0, + ); + let c2 = ( + p2.0 - tension * (p3.0 - p1.0) / 3.0, + p2.1 - tension * (p3.1 - p1.1) / 3.0, + ); + + beziers.push((c1, c2, p2)); + } + } else { + // Standard B-spline for 's' command + // Uses uniform cubic B-spline to Bezier conversion + for i in 0..points.len() - 1 { + let p0 = if i > 0 { points[i - 1] } else { points[i] }; + let p1 = points[i]; + let p2 = points[i + 1]; + let p3 = if i + 2 < points.len() { + points[i + 2] + } else { + points[i + 1] + }; + + // Calculate control points for cubic bezier from B-spline + let c1 = (p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0); + let c2 = (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0); + + beziers.push((c1, c2, p2)); + } + } + + beziers +} diff --git a/crates/ass-renderer/src/pipeline/effects/animation.rs b/crates/ass-renderer/src/pipeline/effects/animation.rs new file mode 100644 index 0000000..628ca25 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/effects/animation.rs @@ -0,0 +1,165 @@ +//! Animation effect for interpolating values over time + +use crate::pipeline::effects::Effect; +use crate::utils::{cubic_bezier, lerp, RenderError}; + +/// Animation interpolation type +#[derive(Debug, Clone, Copy)] +pub enum InterpolationType { + /// Linear interpolation + Linear, + /// Ease-in (slow start) + EaseIn, + /// Ease-out (slow end) + EaseOut, + /// Ease-in-out (slow start and end) + EaseInOut, + /// Custom cubic bezier + CubicBezier { p1: f32, p2: f32 }, +} + +impl InterpolationType { + /// Calculate interpolation value + pub fn interpolate(&self, t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + + match self { + Self::Linear => t, + Self::EaseIn => t * t, + Self::EaseOut => t * (2.0 - t), + Self::EaseInOut => { + if t < 0.5 { + 2.0 * t * t + } else { + -1.0 + (4.0 - 2.0 * t) * t + } + } + Self::CubicBezier { p1, p2 } => cubic_bezier(t, *p1, *p2), + } + } +} + +/// Animation effect for time-based transformations +pub struct AnimationEffect { + start_time: f32, + end_time: f32, + current_time: f32, + interpolation: InterpolationType, + property: AnimationProperty, +} + +/// Animated property +#[derive(Debug, Clone)] +pub enum AnimationProperty { + /// Opacity animation + Opacity { start: f32, end: f32 }, + /// Position animation + Position { + start_x: f32, + start_y: f32, + end_x: f32, + end_y: f32, + }, + /// Scale animation + Scale { + start_x: f32, + start_y: f32, + end_x: f32, + end_y: f32, + }, + /// Rotation animation + Rotation { start: f32, end: f32 }, + /// Color animation + Color { start: [u8; 4], end: [u8; 4] }, +} + +impl AnimationEffect { + /// Create a new animation effect + pub fn new( + start_time: f32, + end_time: f32, + interpolation: InterpolationType, + property: AnimationProperty, + ) -> Self { + Self { + start_time, + end_time, + current_time: 0.0, + interpolation, + property, + } + } + + /// Set current animation time + pub fn set_time(&mut self, time: f32) { + self.current_time = time; + } + + /// Get animation progress (0.0 to 1.0) + pub fn progress(&self) -> f32 { + if self.current_time <= self.start_time { + return 0.0; + } + if self.current_time >= self.end_time { + return 1.0; + } + + let duration = self.end_time - self.start_time; + if duration <= 0.0 { + return 1.0; + } + + (self.current_time - self.start_time) / duration + } + + /// Get interpolated value at current time + pub fn interpolated_progress(&self) -> f32 { + self.interpolation.interpolate(self.progress()) + } +} + +impl Effect for AnimationEffect { + fn apply(&self, pixels: &mut [u8], width: u32, height: u32) -> Result<(), RenderError> { + if pixels.len() != (width * height * 4) as usize { + return Err(RenderError::InvalidBufferSize { + expected: (width * height * 4) as usize, + actual: pixels.len(), + }); + } + + let t = self.interpolated_progress(); + + match &self.property { + AnimationProperty::Opacity { start, end } => { + let opacity = lerp(*start, *end, t); + let _opacity_byte = (opacity * 255.0) as u8; + + // Apply opacity to alpha channel + for pixel in pixels.chunks_exact_mut(4) { + pixel[3] = (pixel[3] as f32 * opacity) as u8; + } + } + AnimationProperty::Color { start, end } => { + // Blend colors + for pixel in pixels.chunks_exact_mut(4) { + for i in 0..4 { + pixel[i] = lerp(start[i] as f32, end[i] as f32, t) as u8; + } + } + } + AnimationProperty::Position { .. } + | AnimationProperty::Scale { .. } + | AnimationProperty::Rotation { .. } => { + // These would require geometric transformations + // which should be handled by TransformEffect + // This is a placeholder + } + } + + Ok(()) + } + + fn name(&self) -> &str { + "Animation" + } +} diff --git a/crates/ass-renderer/src/pipeline/effects/blur.rs b/crates/ass-renderer/src/pipeline/effects/blur.rs new file mode 100644 index 0000000..5416f21 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/effects/blur.rs @@ -0,0 +1,212 @@ +//! Blur effect implementations + +#[cfg(feature = "nostd")] +use alloc::{vec, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::vec::Vec; + +use crate::pipeline::effects::Effect; +use crate::utils::RenderError; + +/// Gaussian blur effect +pub struct GaussianBlur { + radius: f32, + sigma: f32, +} + +impl GaussianBlur { + /// Create a new Gaussian blur effect + pub fn new(radius: f32) -> Self { + let sigma = radius / 3.0; + Self { radius, sigma } + } + + /// Create kernel for Gaussian blur + fn create_kernel(&self) -> Vec { + let size = (self.radius * 2.0) as usize + 1; + let mut kernel = Vec::with_capacity(size); + + let sigma2 = self.sigma * self.sigma; + let norm = 1.0 / (2.0 * core::f32::consts::PI * sigma2).sqrt(); + + let half = size / 2; + let mut sum = 0.0; + + for i in 0..size { + let x = i as f32 - half as f32; + let weight = norm * (-x * x / (2.0 * sigma2)).exp(); + kernel.push(weight); + sum += weight; + } + + // Normalize kernel + for weight in &mut kernel { + *weight /= sum; + } + + kernel + } + + /// Apply horizontal blur pass + fn blur_horizontal(&self, pixels: &mut [u8], width: u32, height: u32, kernel: &[f32]) { + let stride = width * 4; + let half_kernel = kernel.len() / 2; + let mut temp = vec![0u8; pixels.len()]; + + for y in 0..height { + for x in 0..width { + let mut r = 0.0; + let mut g = 0.0; + let mut b = 0.0; + let mut a = 0.0; + + for (i, &weight) in kernel.iter().enumerate() { + let sx = (x as i32 + i as i32 - half_kernel as i32) + .max(0) + .min(width as i32 - 1) as u32; + + let idx = (y * stride + sx * 4) as usize; + r += pixels[idx] as f32 * weight; + g += pixels[idx + 1] as f32 * weight; + b += pixels[idx + 2] as f32 * weight; + a += pixels[idx + 3] as f32 * weight; + } + + let idx = (y * stride + x * 4) as usize; + temp[idx] = r.round() as u8; + temp[idx + 1] = g.round() as u8; + temp[idx + 2] = b.round() as u8; + temp[idx + 3] = a.round() as u8; + } + } + + pixels.copy_from_slice(&temp); + } + + /// Apply vertical blur pass + fn blur_vertical(&self, pixels: &mut [u8], width: u32, height: u32, kernel: &[f32]) { + let stride = width * 4; + let half_kernel = kernel.len() / 2; + let mut temp = vec![0u8; pixels.len()]; + + for y in 0..height { + for x in 0..width { + let mut r = 0.0; + let mut g = 0.0; + let mut b = 0.0; + let mut a = 0.0; + + for (i, &weight) in kernel.iter().enumerate() { + let sy = (y as i32 + i as i32 - half_kernel as i32) + .max(0) + .min(height as i32 - 1) as u32; + + let idx = (sy * stride + x * 4) as usize; + r += pixels[idx] as f32 * weight; + g += pixels[idx + 1] as f32 * weight; + b += pixels[idx + 2] as f32 * weight; + a += pixels[idx + 3] as f32 * weight; + } + + let idx = (y * stride + x * 4) as usize; + temp[idx] = r.round() as u8; + temp[idx + 1] = g.round() as u8; + temp[idx + 2] = b.round() as u8; + temp[idx + 3] = a.round() as u8; + } + } + + pixels.copy_from_slice(&temp); + } +} + +impl Effect for GaussianBlur { + fn apply(&self, pixels: &mut [u8], width: u32, height: u32) -> Result<(), RenderError> { + if pixels.len() != (width * height * 4) as usize { + return Err(RenderError::InvalidBufferSize { + expected: (width * height * 4) as usize, + actual: pixels.len(), + }); + } + + let kernel = self.create_kernel(); + + // Two-pass separable blur + self.blur_horizontal(pixels, width, height, &kernel); + self.blur_vertical(pixels, width, height, &kernel); + + Ok(()) + } + + fn name(&self) -> &str { + "GaussianBlur" + } +} + +/// Box blur effect (faster but lower quality) +pub struct BoxBlur { + radius: u32, +} + +impl BoxBlur { + /// Create a new box blur effect + pub fn new(radius: u32) -> Self { + Self { radius } + } +} + +impl Effect for BoxBlur { + fn apply(&self, pixels: &mut [u8], width: u32, height: u32) -> Result<(), RenderError> { + if pixels.len() != (width * height * 4) as usize { + return Err(RenderError::InvalidBufferSize { + expected: (width * height * 4) as usize, + actual: pixels.len(), + }); + } + + let stride = width * 4; + let mut temp = vec![0u8; pixels.len()]; + + // Simple box blur implementation + for y in 0..height { + for x in 0..width { + let mut r = 0u32; + let mut g = 0u32; + let mut b = 0u32; + let mut a = 0u32; + let mut count = 0u32; + + for dy in 0..=self.radius * 2 { + for dx in 0..=self.radius * 2 { + let sy = (y as i32 + dy as i32 - self.radius as i32) + .max(0) + .min(height as i32 - 1) as u32; + let sx = (x as i32 + dx as i32 - self.radius as i32) + .max(0) + .min(width as i32 - 1) as u32; + + let idx = (sy * stride + sx * 4) as usize; + r += pixels[idx] as u32; + g += pixels[idx + 1] as u32; + b += pixels[idx + 2] as u32; + a += pixels[idx + 3] as u32; + count += 1; + } + } + + let idx = (y * stride + x * 4) as usize; + temp[idx] = (r / count) as u8; + temp[idx + 1] = (g / count) as u8; + temp[idx + 2] = (b / count) as u8; + temp[idx + 3] = (a / count) as u8; + } + } + + pixels.copy_from_slice(&temp); + Ok(()) + } + + fn name(&self) -> &str { + "BoxBlur" + } +} diff --git a/crates/ass-renderer/src/pipeline/effects/mod.rs b/crates/ass-renderer/src/pipeline/effects/mod.rs new file mode 100644 index 0000000..4ff1050 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/effects/mod.rs @@ -0,0 +1,80 @@ +//! Effect processing pipeline + +#[cfg(feature = "nostd")] +use alloc::boxed::Box; +#[cfg(not(feature = "nostd"))] +use std::boxed::Box; + +use crate::utils::RenderError; +use smallvec::SmallVec; + +mod animation; +mod blur; +mod transform; + +pub use animation::{AnimationEffect, InterpolationType}; +pub use blur::{BoxBlur, GaussianBlur}; +pub use transform::TransformEffect; + +/// Effect trait for applying visual effects +pub trait Effect: Send + Sync { + /// Apply effect to pixel data + fn apply(&self, pixels: &mut [u8], width: u32, height: u32) -> Result<(), RenderError>; + + /// Get effect name + fn name(&self) -> &str; + + /// Check if effect can be combined with another + fn can_combine_with(&self, other: &dyn Effect) -> bool { + let _ = other; + false + } +} + +/// Effect chain for applying multiple effects +pub struct EffectChain { + effects: SmallVec<[Box; 8]>, +} + +impl EffectChain { + /// Create a new effect chain + pub fn new() -> Self { + Self { + effects: SmallVec::new(), + } + } + + /// Add an effect to the chain + pub fn add(&mut self, effect: Box) { + self.effects.push(effect); + } + + /// Apply all effects in the chain + pub fn apply(&self, pixels: &mut [u8], width: u32, height: u32) -> Result<(), RenderError> { + for effect in &self.effects { + effect.apply(pixels, width, height)?; + } + Ok(()) + } + + /// Get number of effects in chain + pub fn len(&self) -> usize { + self.effects.len() + } + + /// Check if chain is empty + pub fn is_empty(&self) -> bool { + self.effects.is_empty() + } + + /// Clear all effects + pub fn clear(&mut self) { + self.effects.clear(); + } +} + +impl Default for EffectChain { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ass-renderer/src/pipeline/effects/transform.rs b/crates/ass-renderer/src/pipeline/effects/transform.rs new file mode 100644 index 0000000..d266c30 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/effects/transform.rs @@ -0,0 +1,128 @@ +//! Transform effect for geometric transformations + +#[cfg(feature = "nostd")] +use alloc::vec; + +use crate::pipeline::effects::Effect; +use crate::utils::{Matrix3x3, RenderError}; + +/// Transform effect for applying geometric transformations +pub struct TransformEffect { + matrix: Matrix3x3, +} + +impl TransformEffect { + /// Create a new transform effect with identity matrix + pub fn new() -> Self { + Self { + matrix: Matrix3x3::identity(), + } + } + + /// Create transform from matrix + pub fn from_matrix(matrix: Matrix3x3) -> Self { + Self { matrix } + } + + /// Set translation + pub fn translate(mut self, x: f32, y: f32) -> Self { + self.matrix = self.matrix.multiply(&Matrix3x3::translate(x, y)); + self + } + + /// Set scale + pub fn scale(mut self, sx: f32, sy: f32) -> Self { + self.matrix = self.matrix.multiply(&Matrix3x3::scale(sx, sy)); + self + } + + /// Set rotation (angle in radians) + pub fn rotate(mut self, angle: f32) -> Self { + self.matrix = self.matrix.multiply(&Matrix3x3::rotate(angle)); + self + } + + /// Apply bilinear interpolation for pixel sampling + fn sample_bilinear(pixels: &[u8], x: f32, y: f32, width: u32, height: u32) -> [u8; 4] { + let x0 = x.floor() as i32; + let y0 = y.floor() as i32; + let x1 = x0 + 1; + let y1 = y0 + 1; + + let fx = x - x0 as f32; + let fy = y - y0 as f32; + + let mut result = [0u8; 4]; + + // Sample four neighboring pixels + let p00 = Self::get_pixel(pixels, x0, y0, width, height); + let p10 = Self::get_pixel(pixels, x1, y0, width, height); + let p01 = Self::get_pixel(pixels, x0, y1, width, height); + let p11 = Self::get_pixel(pixels, x1, y1, width, height); + + // Bilinear interpolation + for i in 0..4 { + let v0 = p00[i] as f32 * (1.0 - fx) + p10[i] as f32 * fx; + let v1 = p01[i] as f32 * (1.0 - fx) + p11[i] as f32 * fx; + result[i] = (v0 * (1.0 - fy) + v1 * fy) as u8; + } + + result + } + + /// Get pixel value with bounds checking + fn get_pixel(pixels: &[u8], x: i32, y: i32, width: u32, height: u32) -> [u8; 4] { + if x < 0 || x >= width as i32 || y < 0 || y >= height as i32 { + return [0, 0, 0, 0]; + } + + let idx = (y as u32 * width * 4 + x as u32 * 4) as usize; + [ + pixels.get(idx).copied().unwrap_or(0), + pixels.get(idx + 1).copied().unwrap_or(0), + pixels.get(idx + 2).copied().unwrap_or(0), + pixels.get(idx + 3).copied().unwrap_or(0), + ] + } +} + +impl Effect for TransformEffect { + fn apply(&self, pixels: &mut [u8], width: u32, height: u32) -> Result<(), RenderError> { + if pixels.len() != (width * height * 4) as usize { + return Err(RenderError::InvalidBufferSize { + expected: (width * height * 4) as usize, + actual: pixels.len(), + }); + } + + let mut output = vec![0u8; pixels.len()]; + + // Apply inverse transform to sample from source + // This would need proper matrix inversion in production + for y in 0..height { + for x in 0..width { + // Transform destination coordinates to source coordinates + let (src_x, src_y) = self.matrix.transform_point(x as f32, y as f32); + + // Sample from source with bilinear interpolation + let color = Self::sample_bilinear(pixels, src_x, src_y, width, height); + + let idx = (y * width * 4 + x * 4) as usize; + output[idx..idx + 4].copy_from_slice(&color); + } + } + + pixels.copy_from_slice(&output); + Ok(()) + } + + fn name(&self) -> &str { + "Transform" + } +} + +impl Default for TransformEffect { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ass-renderer/src/pipeline/font_loader.rs b/crates/ass-renderer/src/pipeline/font_loader.rs new file mode 100644 index 0000000..2a0bab3 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/font_loader.rs @@ -0,0 +1,92 @@ +//! Font loading from ASS scripts including embedded fonts + +use ass_core::parser::Script; +use fontdb::Database as FontDatabase; + +/// Load embedded fonts from ASS script into font database +pub fn load_embedded_fonts(script: &Script, font_database: &mut FontDatabase) { + // Check if the script has a Fonts section + if let Some(fonts) = script.sections().iter().find_map(|section| { + if let ass_core::parser::ast::Section::Fonts(fonts) = section { + Some(fonts) + } else { + None + } + }) { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("Found {} embedded fonts in script", fonts.len()); + + // Process each embedded font + for font in fonts { + // Decode the UU-encoded font data + match font.decode_data() { + Ok(font_data) => { + // Load the font into the database + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!( + "Loading embedded font: {} ({} bytes)", + font.filename, + font_data.len() + ); + + // fontdb's load_font_data takes ownership of the data + font_database.load_font_data(font_data); + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("Successfully loaded embedded font: {}", font.filename); + } + Err(_e) => { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("Failed to decode embedded font {}: {:?}", font.filename, _e); + } + } + } + } +} + +/// Load fonts from file paths specified in script metadata +pub fn load_font_files( + script: &Script, + #[cfg_attr(feature = "nostd", allow(unused_variables))] font_database: &mut FontDatabase, +) { + // Check Script Info section for font file references + if let Some(script_info) = script.sections().iter().find_map(|section| { + if let ass_core::parser::ast::Section::ScriptInfo(info) = section { + Some(info) + } else { + None + } + }) { + // Look for font file declarations in fields + for (key, value) in &script_info.fields { + // Some scripts use "Font:" or "Fontname:" fields to specify external fonts + if key.to_lowercase().contains("font") { + let _font_path = value.trim(); + + #[cfg(not(feature = "nostd"))] + { + // Try to load as a file path if it exists + let path = std::path::Path::new(_font_path); + if path.exists() && path.is_file() { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("Loading external font file: {_font_path}"); + + if let Err(_e) = font_database.load_font_file(path) { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("Failed to load font file {_font_path}: {_e:?}"); + } + } + } + } + } + } +} + +/// Load all fonts referenced in an ASS script +pub fn load_script_fonts(script: &Script, font_database: &mut FontDatabase) { + // Load embedded fonts from [Fonts] section + load_embedded_fonts(script, font_database); + + // Load external font files if referenced + load_font_files(script, font_database); +} diff --git a/crates/ass-renderer/src/pipeline/mod.rs b/crates/ass-renderer/src/pipeline/mod.rs new file mode 100644 index 0000000..8d3f652 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/mod.rs @@ -0,0 +1,206 @@ +//! Rendering pipeline for processing events into layers + +use crate::renderer::RenderContext; +use crate::utils::{DirtyRegion, RenderError}; +#[cfg(feature = "analysis-integration")] +use ass_core::analysis::ScriptAnalysis; +use ass_core::parser::{Event, Script}; +use smallvec::SmallVec; + +#[cfg(feature = "nostd")] +use alloc::{string::String, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::{string::String, vec::Vec}; + +pub mod animation; +pub mod compositing; +pub mod drawing; +pub mod effects; +pub mod font_loader; +pub mod shaping; +pub mod tag_processor; +pub mod text_segmenter; +pub mod transform; +pub mod validation; + +mod software_pipeline; +// mod software_pipeline_new; // Not currently used - fixes are already in main pipeline +pub use software_pipeline::SoftwarePipeline; + +/// Pipeline trait for processing events +pub trait Pipeline: Send + Sync { + /// Prepare the pipeline with a script + fn prepare_script( + &mut self, + script: &Script, + #[cfg(feature = "analysis-integration")] analysis: Option<&ScriptAnalysis>, + #[cfg(not(feature = "analysis-integration"))] _analysis: Option<()>, + ) -> Result<(), RenderError>; + + /// Get the current script + fn script(&self) -> Option<&Script<'_>>; + + /// Process events into intermediate layers + fn process_events( + &mut self, + events: &[&Event], + time_cs: u32, + context: &RenderContext, + ) -> Result, RenderError>; + + /// Compute dirty regions for incremental rendering + fn compute_dirty_regions( + &self, + events: &[&Event], + time_cs: u32, + prev_time_cs: u32, + ) -> Result, RenderError>; +} + +/// Pipeline stage for processing +#[derive(Debug, Clone, Copy)] +pub enum PipelineStage { + /// Text shaping stage + Shaping, + /// Drawing command processing + Drawing, + /// Effect application + Effects, + /// Layer compositing + Compositing, +} + +/// Intermediate layer representation +pub enum IntermediateLayer { + /// Rasterized bitmap layer + Raster(RasterData), + /// Vector graphics layer + Vector(VectorData), + /// Text layer + Text(TextData), +} + +impl IntermediateLayer { + /// Check if layer intersects with a dirty region + pub fn intersects_region(&self, region: &DirtyRegion) -> bool { + match self { + Self::Raster(data) => { + let layer_bounds = (data.x, data.y, data.x + data.width, data.y + data.height); + region.intersects(layer_bounds) + } + Self::Vector(data) => { + if let Some(bounds) = &data.bounds { + region.intersects(*bounds) + } else { + true + } + } + Self::Text(data) => { + let approx_bounds = ( + data.x as u32, + data.y as u32, + (data.x + 200.0) as u32, + (data.y + data.font_size * 1.5) as u32, + ); + region.intersects(approx_bounds) + } + } + } +} + +/// Raster layer data +pub struct RasterData { + /// Pixel data (RGBA) + pub pixels: Vec, + /// Layer X position + pub x: u32, + /// Layer Y position + pub y: u32, + /// Layer width + pub width: u32, + /// Layer height + pub height: u32, + /// Opacity (0-255) + pub opacity: u8, +} + +/// Vector graphics layer data +pub struct VectorData { + /// Path to draw + pub path: Option, + /// Fill color (RGBA) + pub color: [u8; 4], + /// Stroke information + pub stroke: Option, + /// Bounding box + pub bounds: Option<(u32, u32, u32, u32)>, +} + +/// Stroke information for vector graphics +pub struct StrokeInfo { + /// Stroke color (RGBA) + pub color: [u8; 4], + /// Stroke width + pub width: f32, +} + +/// Text layer data +pub struct TextData { + /// Text content + pub text: String, + /// Font family + pub font_family: String, + /// Font size in pixels + pub font_size: f32, + /// Text color (RGBA) + pub color: [u8; 4], + /// X position + pub x: f32, + /// Y position + pub y: f32, + /// Text effects + pub effects: SmallVec<[TextEffect; 4]>, + /// Letter spacing in pixels + pub spacing: f32, +} + +/// Text effect enumeration +#[derive(Clone, Debug)] +pub enum TextEffect { + /// Bold text + Bold, + /// Italic text + Italic, + /// Underline + Underline, + /// Strikethrough + Strikethrough, + /// Outline with color and width + Outline { color: [u8; 4], width: f32 }, + /// Shadow with color and offset + Shadow { + color: [u8; 4], + x_offset: f32, + y_offset: f32, + }, + /// Blur effect + Blur { radius: f32 }, + /// Edge blur effect (only blurs outline/edges) + EdgeBlur { radius: f32 }, + /// Karaoke effect + Karaoke { progress: f32, style: u8 }, + /// 3D rotation (in degrees) + Rotation { x: f32, y: f32, z: f32 }, + /// Shear/skew transformation + Shear { x: f32, y: f32 }, + /// Scale transformation + Scale { x: f32, y: f32 }, + /// Clip region + Clip { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + inverse: bool, + }, +} diff --git a/crates/ass-renderer/src/pipeline/shaping/font_metrics.rs b/crates/ass-renderer/src/pipeline/shaping/font_metrics.rs new file mode 100644 index 0000000..4a2b841 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/shaping/font_metrics.rs @@ -0,0 +1,92 @@ +//! Font metrics handling for libass/VSFilter compatibility + +use ttf_parser::Face; + +/// Font metrics with VSFilter compatibility +#[derive(Debug, Clone)] +pub struct FontMetrics { + /// Ascender value + pub ascender: f32, + /// Descender value (negative) + pub descender: f32, + /// Line gap + pub line_gap: f32, + /// Units per em + pub units_per_em: f32, + /// Whether metrics are from OS/2 table + pub uses_os2: bool, +} + +impl FontMetrics { + /// Get font metrics with VSFilter compatibility + /// + /// Libass uses OS/2 table's usWinAscent/usWinDescent for compatibility with VSFilter. + /// If OS/2 table is not available, falls back to hhea table. + pub fn from_face(face: &Face) -> Self { + let units_per_em = face.units_per_em() as f32; + + // Try to get OS/2 table metrics first (for VSFilter compatibility) + if let Some(os2) = face.tables().os2 { + // Use typographic metrics from OS/2 table if available + // Note: ttf-parser's typographic_ascender/descender return i16 directly + // We'll use these if they're non-zero, otherwise fall back to regular metrics + let typographic_ascender = os2.typographic_ascender(); + let typographic_descender = os2.typographic_descender(); + + let ascender = if typographic_ascender != 0 { + typographic_ascender as f32 + } else { + face.ascender() as f32 + }; + + let descender = if typographic_descender != 0 { + typographic_descender as f32 + } else { + face.descender() as f32 + }; + + return FontMetrics { + ascender, + descender, + line_gap: 0.0, // OS/2 doesn't have line gap in Windows metrics + units_per_em, + uses_os2: true, + }; + } + + // Fall back to hhea table metrics + let ascender = face.ascender() as f32; + let descender = face.descender() as f32; + let line_gap = face.line_gap() as f32; + + FontMetrics { + ascender, + descender, + line_gap, + units_per_em, + uses_os2: false, + } + } + + /// Calculate line height at given font size + /// Match libass behavior: use font size directly as the line height + pub fn line_height(&self, font_size: f32) -> f32 { + // Libass uses the font size directly for line height calculations + // rather than calculating from metrics + font_size + } + + /// Calculate baseline offset at given font size + pub fn baseline(&self, font_size: f32) -> f32 { + // Calculate baseline as a proportion of font size + // Most fonts have baseline at about 80% of font size from top + let scale = font_size / self.units_per_em; + self.ascender * scale + } + + /// Apply font spacing (letter spacing) to advance width + pub fn apply_spacing(advance: f32, spacing: f32, _font_size: f32) -> f32 { + // ASS font spacing is in pixels, added to each character's advance + advance + spacing + } +} diff --git a/crates/ass-renderer/src/pipeline/shaping/mod.rs b/crates/ass-renderer/src/pipeline/shaping/mod.rs new file mode 100644 index 0000000..2ced948 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/shaping/mod.rs @@ -0,0 +1,628 @@ +//! Text shaping module using rustybuzz + +mod font_metrics; +pub use font_metrics::FontMetrics; + +#[cfg(feature = "nostd")] +use alloc::{format, string::ToString, sync::Arc, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::{string::ToString, sync::Arc, vec::Vec}; + +use crate::utils::RenderError; +use ahash::AHashMap; +use fontdb::{Database as FontDatabase, ID as FontId}; +use rustybuzz::{Face, Feature, UnicodeBuffer, Variation}; +use tiny_skia::{Path, PathBuilder}; + +/// Shaped glyph representation +#[derive(Debug, Clone)] +pub struct ShapedGlyph { + /// Glyph ID in the font + pub glyph_id: u32, + /// X position + pub x_position: f32, + /// Y position + pub y_position: f32, + /// X offset + pub x_offset: f32, + /// Y offset + pub y_offset: f32, + /// Horizontal advance + pub x_advance: f32, + /// Vertical advance + pub y_advance: f32, + /// Cluster index in original text + pub cluster: u32, +} + +/// Shaped text result +#[derive(Debug, Clone)] +pub struct ShapedText { + /// Shaped glyphs + pub glyphs: Vec, + /// Total width + pub width: f32, + /// Total height (line height) + pub height: f32, + /// Baseline position + pub baseline: f32, + /// Font size used for shaping + pub font_size: f32, + /// Font ascent (for underline/strikeout positioning) + pub ascent: f32, + /// Font descent (for underline/strikeout positioning) + pub descent: f32, +} + +impl ShapedText { + /// Get the total horizontal advance of the shaped text + pub fn total_advance(&self) -> Option { + if self.glyphs.is_empty() { + return Some(0.0); + } + + // Calculate the total advance by summing up all glyph advances + let mut total = 0.0; + for glyph in &self.glyphs { + total += glyph.x_advance; + } + Some(total) + } +} + +/// Shape text into glyphs +pub fn shape_text( + text: &str, + font_family: &str, + font_size: f32, + font_database: &FontDatabase, +) -> Result { + shape_text_with_style(text, font_family, font_size, false, false, font_database) +} + +/// Shape text with style options +pub fn shape_text_with_style( + text: &str, + font_family: &str, + font_size: f32, + bold: bool, + italic: bool, + font_database: &FontDatabase, +) -> Result { + // Find best matching font, taking the input text into account for coverage (CJK/Hangul, etc.) + let font_id = find_font_for_text(font_database, font_family, bold, italic, text)?; + + // Get font data using face_source + let (source, index) = font_database + .face_source(font_id) + .ok_or_else(|| RenderError::FontError("Failed to load font data".to_string()))?; + + // Extract data based on source type + let font_data = match source { + fontdb::Source::Binary(data) => data, + fontdb::Source::File(_path) => { + #[cfg(not(feature = "nostd"))] + { + std::sync::Arc::new(std::fs::read(&_path).map_err(|e| { + RenderError::FontError(format!("Failed to read font file: {e}")) + })?) + } + #[cfg(feature = "nostd")] + { + return Err(RenderError::FontError( + "File reading not supported in no_std mode".into(), + )); + } + } + fontdb::Source::SharedFile(_, data) => data, + }; + + // Create rustybuzz face + let rb_face = Face::from_slice(font_data.as_ref().as_ref(), index) + .ok_or_else(|| RenderError::FontError("Failed to create font face".to_string()))?; + + // Parse with ttf-parser for OS/2 table access + let ttf_face = ttf_parser::Face::parse(font_data.as_ref().as_ref(), index) + .map_err(|_| RenderError::FontError("Failed to parse font for metrics".to_string()))?; + + // Get font metrics with VSFilter compatibility + let metrics = FontMetrics::from_face(&ttf_face); + + // Create buffer and add text + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + + // Shape the text + let features: Vec = Vec::new(); + let _variations: Vec = Vec::new(); + let output = rustybuzz::shape(&rb_face, &features, buffer); + + // Get glyph positions and convert to our format + let positions = output.glyph_positions(); + let infos = output.glyph_infos(); + + let mut glyphs = Vec::new(); + let mut cursor_x = 0.0; + let mut cursor_y = 0.0; + + let scale = font_size / metrics.units_per_em; + + for (info, pos) in infos.iter().zip(positions.iter()) { + glyphs.push(ShapedGlyph { + glyph_id: info.glyph_id, + x_position: cursor_x + pos.x_offset as f32 * scale, + y_position: cursor_y + pos.y_offset as f32 * scale, + x_offset: pos.x_offset as f32 * scale, + y_offset: pos.y_offset as f32 * scale, + x_advance: pos.x_advance as f32 * scale, + y_advance: pos.y_advance as f32 * scale, + cluster: info.cluster, + }); + + cursor_x += pos.x_advance as f32 * scale; + cursor_y += pos.y_advance as f32 * scale; + } + + // Calculate metrics using VSFilter-compatible values + let height = metrics.line_height(font_size); + let baseline = metrics.baseline(font_size); + let ascent = metrics.ascender * scale; + let descent = metrics.descender * scale; + + Ok(ShapedText { + width: cursor_x, + height, + baseline, + font_size, + glyphs, + ascent, + descent, + }) +} + +/// Find matching font in database with fallback support for CJK/Chinese/Japanese/Korean text +fn find_font( + font_database: &FontDatabase, + family: &str, + bold: bool, + italic: bool, +) -> Result { + // First try the requested font + let query = fontdb::Query { + families: &[fontdb::Family::Name(family), fontdb::Family::SansSerif], + weight: if bold { + fontdb::Weight::BOLD + } else { + fontdb::Weight::NORMAL + }, + stretch: fontdb::Stretch::Normal, + style: if italic { + fontdb::Style::Italic + } else { + fontdb::Style::Normal + }, + }; + + if let Some(id) = font_database.query(&query) { + return Ok(id); + } + + // If the requested font contains "CJK" or is a known Japanese font name, try fallbacks + let is_japanese_font = family.contains("CJK") + || family.contains("Noto Sans JP") + || family.contains("Hiragino") + || family.contains("Yu Gothic") + || family.contains("MS Gothic") + || family.contains("Meiryo"); + + if is_japanese_font { + // Try common Japanese font fallbacks + let japanese_fallbacks = [ + "Hiragino Sans", + "Hiragino Kaku Gothic Pro", + "Yu Gothic", + "MS Gothic", + "Meiryo", + "Noto Sans CJK JP", + "Noto Sans JP", + ]; + + for fallback in &japanese_fallbacks { + let fallback_query = fontdb::Query { + families: &[fontdb::Family::Name(fallback)], + weight: if bold { + fontdb::Weight::BOLD + } else { + fontdb::Weight::NORMAL + }, + stretch: fontdb::Stretch::Normal, + style: if italic { + fontdb::Style::Italic + } else { + fontdb::Style::Normal + }, + }; + + if let Some(id) = font_database.query(&fallback_query) { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + println!("Using Japanese font fallback: {family} -> {fallback}"); + return Ok(id); + } + } + } + + // Final fallback to any available font + let final_query = fontdb::Query { + families: &[fontdb::Family::SansSerif, fontdb::Family::Serif], + weight: if bold { + fontdb::Weight::BOLD + } else { + fontdb::Weight::NORMAL + }, + stretch: fontdb::Stretch::Normal, + style: if italic { + fontdb::Style::Italic + } else { + fontdb::Style::Normal + }, + }; + + font_database.query(&final_query).ok_or_else(|| { + RenderError::FontError(format!( + "Font '{family}' not found and no fallback available" + )) + }) +} + +/// Determine if text contains significant CJK/Hangul content +fn text_has_cjk_or_hangul(text: &str) -> bool { + text.chars().any(|ch| { + let u = ch as u32; + // CJK Unified Ideographs + (0x4E00..=0x9FFF).contains(&u) + // CJK Unified Ideographs Extension A/B (basic check) + || (0x3400..=0x4DBF).contains(&u) + || (0x20000..=0x2A6DF).contains(&u) + // Hangul Syllables + Jamo + || (0xAC00..=0xD7AF).contains(&u) + || (0x1100..=0x11FF).contains(&u) + // Hiragana, Katakana + || (0x3040..=0x30FF).contains(&u) + }) +} + +/// Check how many characters of `text` are supported by the font face +fn font_support_count( + font_database: &FontDatabase, + font_id: FontId, + text: &str, +) -> Result { + let (source, index) = font_database + .face_source(font_id) + .ok_or_else(|| RenderError::FontError("Failed to load font data".to_string()))?; + + let data: std::sync::Arc + Send + Sync> = match source { + fontdb::Source::Binary(data) => data, + fontdb::Source::File(path) => { + #[cfg(not(feature = "nostd"))] + { + std::sync::Arc::new(std::fs::read(&path).map_err(|e| { + RenderError::FontError(format!("Failed to read font file: {e}")) + })?) + } + #[cfg(feature = "nostd")] + { + return Err(RenderError::FontError( + "File reading not supported in no_std mode".into(), + )); + } + } + fontdb::Source::SharedFile(_, data) => data, + }; + + let face = ttf_parser::Face::parse(data.as_ref().as_ref(), index) + .map_err(|_| RenderError::FontError("Failed to parse font".to_string()))?; + + // Count support for BMP chars in text + let mut count = 0usize; + for ch in text.chars() { + if face.glyph_index(ch).is_some() { + count += 1; + } + } + Ok(count) +} + +/// Attempt to pick a font that best covers the input text while respecting the requested family if possible +fn find_font_for_text( + font_database: &FontDatabase, + family: &str, + bold: bool, + italic: bool, + text: &str, +) -> Result { + // First, try the requested font (or its immediate fallback in find_font) + let primary = find_font(font_database, family, bold, italic)?; + let primary_support = font_support_count(font_database, primary, text)?; + let text_len = text.chars().count().max(1); + + if primary_support == text_len { + return Ok(primary); + } + + // If the text contains CJK/Hangul, try curated fallback lists first + if text_has_cjk_or_hangul(text) { + let curated_lists: &[&[&str]] = &[ + // Simplified Chinese + &[ + "Noto Sans CJK SC", + "Noto Sans SC", + "Source Han Sans CN", + "PingFang SC", + "Microsoft YaHei", + "SimHei", + "SimSun", + ], + // Traditional Chinese + &[ + "Noto Sans CJK TC", + "Noto Sans TC", + "Source Han Sans TW", + "PingFang TC", + "Heiti TC", + ], + // Japanese + &[ + "Noto Sans CJK JP", + "Noto Sans JP", + "Hiragino Sans", + "Hiragino Kaku Gothic Pro", + "Yu Gothic", + "MS Gothic", + "Meiryo", + ], + // Korean + &[ + "Noto Sans CJK KR", + "Noto Sans KR", + "Apple SD Gothic Neo", + "Malgun Gothic", + ], + // Generic CJK + &[ + "Source Han Sans", + "Noto Sans CJK", + ], + ]; + + let mut best_font = None; + let mut best_support = primary_support; + + for list in curated_lists { + for &name in *list { + let query = fontdb::Query { + families: &[fontdb::Family::Name(name)], + weight: if bold { fontdb::Weight::BOLD } else { fontdb::Weight::NORMAL }, + stretch: fontdb::Stretch::Normal, + style: if italic { fontdb::Style::Italic } else { fontdb::Style::Normal }, + }; + if let Some(id) = font_database.query(&query) { + let support = font_support_count(font_database, id, text)?; + if support == text_len { + return Ok(id); + } + if support > best_support { + best_support = support; + best_font = Some(id); + } + } + } + } + + if let Some(id) = best_font { + return Ok(id); + } + } + + // As a last resort, scan all faces to find the best coverage + let mut best_font = primary; + let mut best_support = primary_support; + for face in font_database.faces() { + // Skip if it's the same font + if face.id == primary { + continue; + } + let id = face.id; + let support = font_support_count(font_database, id, text)?; + if support > best_support { + best_support = support; + best_font = id; + if best_support == text_len { + break; + } + } + } + + Ok(best_font) +} + +/// Glyph rendering context for caching +pub struct GlyphRenderer { + glyph_cache: AHashMap, + font_cache: AHashMap + Send + Sync>>, + // Cache TTC/OTF face index so we render outlines from the correct subface + font_index_cache: AHashMap, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +struct GlyphKey { + font_id: FontId, + glyph_id: u32, + size: u32, // Font size in fixed point (16.16) +} + +impl Default for GlyphRenderer { + fn default() -> Self { + Self::new() + } +} + +impl GlyphRenderer { + /// Create new glyph renderer + pub fn new() -> Self { + Self { + glyph_cache: AHashMap::new(), + font_cache: AHashMap::new(), + font_index_cache: AHashMap::new(), + } + } + + /// Render shaped text to paths + pub fn render_shaped_text( + &mut self, + shaped: &ShapedText, + font_id: FontId, + font_database: &FontDatabase, + spacing: f32, + ) -> Result, RenderError> { + // Get or cache font data and face index + let (font_data, face_index) = if let Some(data) = self.font_cache.get(&font_id) { + let idx = *self.font_index_cache.get(&font_id).unwrap_or(&0); + (data.clone(), idx) + } else { + let (source, index) = font_database + .face_source(font_id) + .ok_or_else(|| RenderError::FontError("Failed to load font data".to_string()))?; + + let data = match source { + fontdb::Source::Binary(data) => data, + fontdb::Source::File(_path) => { + #[cfg(not(feature = "nostd"))] + { + std::sync::Arc::new(std::fs::read(&_path).map_err(|e| { + RenderError::FontError(format!("Failed to read font file: {e}")) + })?) + } + #[cfg(feature = "nostd")] + { + return Err(RenderError::FontError( + "File reading not supported in no_std mode".into(), + )); + } + } + fontdb::Source::SharedFile(_, data) => data, + }; + + self.font_cache.insert(font_id, data.clone()); + self.font_index_cache.insert(font_id, index); + (data, index) + }; + + // Parse font with ttf-parser using the correct subface index + let font = ttf_parser::Face::parse(font_data.as_ref().as_ref(), face_index) + .map_err(|_| RenderError::FontError("Failed to parse font".to_string()))?; + + let mut paths = Vec::new(); + let mut accumulated_spacing = 0.0; + + // Render each glyph + for (i, glyph) in shaped.glyphs.iter().enumerate() { + let size_fixed = (shaped.font_size * 65536.0) as u32; // Convert to 16.16 fixed point + let key = GlyphKey { + font_id, + glyph_id: glyph.glyph_id, + size: size_fixed, + }; + + // Apply spacing to x position (accumulated for all previous glyphs) + let adjusted_x = glyph.x_position + accumulated_spacing; + + // Check cache first + if let Some(cached_path) = self.glyph_cache.get(&key) { + // Transform cached path to glyph position + // y_position is already the baseline position + let transform = tiny_skia::Transform::from_translate(adjusted_x, glyph.y_position); + if let Some(transformed) = cached_path.clone().transform(transform) { + paths.push(transformed); + } + // Add spacing for next glyph (spacing is added after each character) + if i < shaped.glyphs.len() - 1 { + accumulated_spacing += spacing; + } + continue; + } + + // Build glyph path + let mut builder = PathBuilder::new(); + let glyph_id = ttf_parser::GlyphId(glyph.glyph_id as u16); + + // Get glyph outline + if let Some(_bbox) = font.glyph_bounding_box(glyph_id) { + let scale = shaped.font_size / font.units_per_em() as f32; + + // Outline builder to convert ttf-parser outlines to tiny-skia paths + struct OutlineBuilder { + builder: PathBuilder, + scale: f32, + } + + impl ttf_parser::OutlineBuilder for OutlineBuilder { + fn move_to(&mut self, x: f32, y: f32) { + self.builder.move_to(x * self.scale, -y * self.scale); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.builder.line_to(x * self.scale, -y * self.scale); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.builder.quad_to( + x1 * self.scale, + -y1 * self.scale, + x * self.scale, + -y * self.scale, + ); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.builder.cubic_to( + x1 * self.scale, + -y1 * self.scale, + x2 * self.scale, + -y2 * self.scale, + x * self.scale, + -y * self.scale, + ); + } + + fn close(&mut self) { + self.builder.close(); + } + } + + let mut outline_builder = OutlineBuilder { builder, scale }; + font.outline_glyph(glyph_id, &mut outline_builder); + builder = outline_builder.builder; + } + + if let Some(path) = builder.finish() { + // Cache the base glyph path + self.glyph_cache.insert(key, path.clone()); + + // Transform to position with spacing adjustment + // y_position is already the baseline position + let transform = tiny_skia::Transform::from_translate(adjusted_x, glyph.y_position); + if let Some(transformed) = path.transform(transform) { + paths.push(transformed); + } + } + + // Add spacing for next glyph (spacing is added after each character) + if i < shaped.glyphs.len() - 1 { + accumulated_spacing += spacing; + } + } + + Ok(paths) + } +} diff --git a/crates/ass-renderer/src/pipeline/software_pipeline.rs b/crates/ass-renderer/src/pipeline/software_pipeline.rs new file mode 100644 index 0000000..ab07ddf --- /dev/null +++ b/crates/ass-renderer/src/pipeline/software_pipeline.rs @@ -0,0 +1,1910 @@ +//! Fixed software pipeline implementation with proper style resolution + +// Debug macro that only works with std +#[cfg(not(feature = "nostd"))] +macro_rules! debug_println { + ($($arg:tt)*) => { + eprintln!($($arg)*) + }; +} + +#[cfg(feature = "nostd")] +macro_rules! debug_println { + ($($arg:tt)*) => {}; +} + +#[cfg(feature = "nostd")] +use alloc::{ + string::{String, ToString}, + vec, + vec::Vec, +}; +#[cfg(not(feature = "nostd"))] +use std::{ + string::{String, ToString}, + vec::Vec, +}; + +use crate::pipeline::{ + animation::{calculate_fade_progress, calculate_move_progress}, + drawing::process_drawing_commands, + shaping::{shape_text_with_style, GlyphRenderer}, + tag_processor::{KaraokeStyle, ProcessedTags}, + text_segmenter::{segment_text_with_tags, TextSegment}, + transform::{interpolate_alpha, interpolate_color, interpolate_f32, AnimatableTag}, + IntermediateLayer, Pipeline, TextData, TextEffect, VectorData, +}; +use crate::renderer::RenderContext; +use crate::utils::{DirtyRegion, RenderError}; +use ahash::AHashMap; +#[cfg(feature = "analysis-integration")] +use ass_core::analysis::ScriptAnalysis; +use ass_core::parser::{Event, Script, Style}; +use fontdb::Database as FontDatabase; +use smallvec::SmallVec; +use tiny_skia::Transform; + +/// Owned style for storing in pipeline +#[derive(Clone)] +struct OwnedStyle { + #[allow(dead_code)] // Style identifier - stored for completeness + name: String, + fontname: String, + fontsize: String, + primary_colour: String, + secondary_colour: String, + outline_colour: String, + back_colour: String, + bold: String, + italic: String, + underline: String, + strikeout: String, + scale_x: String, + scale_y: String, + spacing: String, + #[allow(dead_code)] // Text rotation angle - stored for completeness + angle: String, + #[allow(dead_code)] // Border rendering style - stored for completeness + border_style: String, + outline: String, + shadow: String, + alignment: String, + margin_l: String, + margin_r: String, + margin_v: String, + #[allow(dead_code)] // Text encoding specification - stored for completeness + encoding: String, +} + +impl OwnedStyle { + fn from_style(style: &Style) -> Self { + Self { + name: style.name.to_string(), + fontname: style.fontname.to_string(), + fontsize: style.fontsize.to_string(), + primary_colour: style.primary_colour.to_string(), + secondary_colour: style.secondary_colour.to_string(), + outline_colour: style.outline_colour.to_string(), + back_colour: style.back_colour.to_string(), + bold: style.bold.to_string(), + italic: style.italic.to_string(), + underline: style.underline.to_string(), + strikeout: style.strikeout.to_string(), + scale_x: style.scale_x.to_string(), + scale_y: style.scale_y.to_string(), + spacing: style.spacing.to_string(), + angle: style.angle.to_string(), + border_style: style.border_style.to_string(), + outline: style.outline.to_string(), + shadow: style.shadow.to_string(), + alignment: style.alignment.to_string(), + margin_l: style.margin_l.to_string(), + margin_r: style.margin_r.to_string(), + margin_v: style.margin_v.to_string(), + encoding: style.encoding.to_string(), + } + } +} + +/// Software rendering pipeline with proper style inheritance +pub struct SoftwarePipeline { + /// Font database for text rendering + font_database: FontDatabase, + /// Glyph renderer + #[allow(dead_code)] // Glyph rendering component - used in future rendering features + glyph_renderer: GlyphRenderer, + /// Collision resolver for subtitle positioning + collision_resolver: crate::collision::CollisionResolver, + /// Render cache for performance + cache: crate::cache::RenderCache, + /// Current script styles map for quick lookup + styles_map: AHashMap, + /// Default style for fallback + default_style: Option, + /// Script playback resolution from PlayResX/PlayResY + play_res_x: f32, + play_res_y: f32, + /// Script layout resolution from LayoutResX/LayoutResY (if present) + layout_res_x: Option, + layout_res_y: Option, + /// Whether to scale border and shadow with video resolution + scaled_border_and_shadow: bool, + /// DPI scale factor for font rendering (default: 0.9) + /// libass uses 72 DPI, some systems use 96 DPI + /// Empirically adjusted to 0.9 for better libass visual match + dpi_scale: f32, +} + +impl Default for SoftwarePipeline { + fn default() -> Self { + Self::new() + } +} + +impl SoftwarePipeline { + /// Create a new fixed software pipeline + pub fn new() -> Self { + let mut font_database = FontDatabase::new(); + font_database.load_system_fonts(); + + Self { + font_database, + glyph_renderer: GlyphRenderer::new(), + collision_resolver: crate::collision::CollisionResolver::new(1920.0, 1080.0), + cache: crate::cache::RenderCache::with_limits(5000, 2000), + styles_map: AHashMap::new(), + default_style: None, + play_res_x: 1920.0, // Default resolution + play_res_y: 1080.0, // Default resolution + layout_res_x: None, + layout_res_y: None, + scaled_border_and_shadow: true, // Default to true per ASS spec + dpi_scale: 0.9, // Adjusted for better libass compatibility (was 0.75) + } + } + + /// Apply transform animations to tags based on current time + fn apply_transform_animations( + &self, + tags: &mut ProcessedTags, + event_start_cs: u32, + current_time_cs: u32, + default_colors: ([u8; 4], [u8; 4], [u8; 4], [u8; 4]), // primary, secondary, outline, shadow + ) { + // Process all transforms (can have multiple) + for transform_data in &tags.transforms { + let animation = &transform_data.animation; + + // Calculate time relative to event start (convert to milliseconds for animation) + let relative_time_ms = if current_time_cs >= event_start_cs { + (current_time_cs - event_start_cs) * 10 // Convert centiseconds to milliseconds + } else { + 0 + }; + + // Calculate animation progress (expects milliseconds) + let progress = animation.calculate_progress(relative_time_ms); + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("TRANSFORM: event_start_cs={}, current_time_cs={}, relative_time_ms={}, progress={}", + event_start_cs, current_time_cs, relative_time_ms, progress); + + // If animation hasn't started or has finished, we might not need to interpolate + if progress <= 0.0 || progress >= 1.0 { + if progress >= 1.0 { + // Apply final values + for target in &animation.target_tags { + match target { + AnimatableTag::FontSize(size) => tags.font.size = Some(*size), + AnimatableTag::FontScaleX(scale) => tags.font.scale_x = Some(*scale), + AnimatableTag::FontScaleY(scale) => tags.font.scale_y = Some(*scale), + AnimatableTag::FontSpacing(spacing) => { + tags.font.spacing = Some(*spacing) + } + AnimatableTag::FontRotationZ(angle) => { + tags.font.rotation_z = Some(*angle) + } + AnimatableTag::FontRotationX(angle) => { + tags.font.rotation_x = Some(*angle) + } + AnimatableTag::FontRotationY(angle) => { + tags.font.rotation_y = Some(*angle) + } + AnimatableTag::PrimaryColor(color) => { + tags.colors.primary = Some(*color) + } + AnimatableTag::SecondaryColor(color) => { + tags.colors.secondary = Some(*color) + } + AnimatableTag::OutlineColor(color) => { + tags.colors.outline = Some(*color) + } + AnimatableTag::ShadowColor(color) => tags.colors.shadow = Some(*color), + AnimatableTag::Alpha(alpha) => tags.colors.alpha = Some(*alpha), + AnimatableTag::BorderWidth(width) => { + tags.formatting.border = Some(*width) + } + AnimatableTag::ShadowDepth(depth) => { + tags.formatting.shadow = Some(*depth) + } + AnimatableTag::Blur(blur) => tags.formatting.blur = Some(*blur), + } + } + } + // Don't return early - we still need to process the rest of the code + // to apply effects like rotation + } else if progress > 0.0 { + // Interpolate values + for target in &animation.target_tags { + match target { + AnimatableTag::FontSize(target_size) => { + if let Some(current) = tags.font.size { + tags.font.size = + Some(interpolate_f32(current, *target_size, progress)); + } else { + tags.font.size = Some(*target_size * progress); + } + } + AnimatableTag::FontScaleX(target_scale) => { + let current = tags.font.scale_x.unwrap_or(100.0); + tags.font.scale_x = + Some(interpolate_f32(current, *target_scale, progress)); + } + AnimatableTag::FontScaleY(target_scale) => { + let current = tags.font.scale_y.unwrap_or(100.0); + tags.font.scale_y = + Some(interpolate_f32(current, *target_scale, progress)); + } + AnimatableTag::FontSpacing(target_spacing) => { + let current = tags.font.spacing.unwrap_or(0.0); + tags.font.spacing = + Some(interpolate_f32(current, *target_spacing, progress)); + } + AnimatableTag::FontRotationZ(target_angle) => { + let current = tags.font.rotation_z.unwrap_or(0.0); + tags.font.rotation_z = + Some(interpolate_f32(current, *target_angle, progress)); + } + AnimatableTag::FontRotationX(target_angle) => { + let current = tags.font.rotation_x.unwrap_or(0.0); + tags.font.rotation_x = + Some(interpolate_f32(current, *target_angle, progress)); + } + AnimatableTag::FontRotationY(target_angle) => { + let current = tags.font.rotation_y.unwrap_or(0.0); + tags.font.rotation_y = + Some(interpolate_f32(current, *target_angle, progress)); + } + AnimatableTag::PrimaryColor(target_color) => { + let current = tags.colors.primary.unwrap_or(default_colors.0); + tags.colors.primary = + Some(interpolate_color(current, *target_color, progress)); + } + AnimatableTag::SecondaryColor(target_color) => { + let current = tags.colors.secondary.unwrap_or(default_colors.1); + tags.colors.secondary = + Some(interpolate_color(current, *target_color, progress)); + } + AnimatableTag::OutlineColor(target_color) => { + let current = tags.colors.outline.unwrap_or(default_colors.2); + tags.colors.outline = + Some(interpolate_color(current, *target_color, progress)); + } + AnimatableTag::ShadowColor(target_color) => { + let current = tags.colors.shadow.unwrap_or(default_colors.3); + tags.colors.shadow = + Some(interpolate_color(current, *target_color, progress)); + } + AnimatableTag::Alpha(target_alpha) => { + let current = tags.colors.alpha.unwrap_or(0); + tags.colors.alpha = + Some(interpolate_alpha(current, *target_alpha, progress)); + } + AnimatableTag::BorderWidth(target_width) => { + let current = tags.formatting.border.unwrap_or(0.0); + tags.formatting.border = + Some(interpolate_f32(current, *target_width, progress)); + } + AnimatableTag::ShadowDepth(target_depth) => { + let current = tags.formatting.shadow.unwrap_or(0.0); + tags.formatting.shadow = + Some(interpolate_f32(current, *target_depth, progress)); + } + AnimatableTag::Blur(target_blur) => { + let current = tags.formatting.blur.unwrap_or(0.0); + tags.formatting.blur = + Some(interpolate_f32(current, *target_blur, progress)); + } + } + } + } + } + } + + /// Create with specific dimensions + pub fn with_dimensions(width: f32, height: f32) -> Self { + let mut font_database = FontDatabase::new(); + font_database.load_system_fonts(); + + Self { + font_database, + glyph_renderer: GlyphRenderer::new(), + collision_resolver: crate::collision::CollisionResolver::new(width, height), + cache: crate::cache::RenderCache::with_limits(5000, 2000), + styles_map: AHashMap::new(), + default_style: None, + play_res_x: width, // Use provided dimensions as default + play_res_y: height, // Use provided dimensions as default + layout_res_x: None, + layout_res_y: None, + scaled_border_and_shadow: true, // Default to true per ASS spec + dpi_scale: 0.9, // Adjusted for better libass compatibility (was 0.75) + } + } + + /// Set DPI scale factor (default is 0.9 for libass compatibility) + /// Use 1.0 for 96 DPI, 0.9 for empirically matched libass rendering + pub fn set_dpi_scale(&mut self, scale: f32) { + self.dpi_scale = scale; + } + + /// Get current DPI scale factor + pub fn dpi_scale(&self) -> f32 { + self.dpi_scale + } + + /// Get style by name from the stored styles + #[allow(dead_code)] // Utility method for style lookup + fn get_style(&self, style_name: &str) -> Option<&OwnedStyle> { + self.styles_map + .get(style_name) + .or(self.default_style.as_ref()) + } + + /// Parse color from ASS format + fn parse_ass_color(color: &str) -> [u8; 4] { + // ASS colors are in &HAABBGGRR format where: + // AA = Alpha (00 = opaque, FF = transparent) + // BB = Blue, GG = Green, RR = Red + let color_trimmed = color.trim_end_matches('&'); + if let Some(hex) = color_trimmed.strip_prefix("&H") { + if let Ok(value) = u32::from_str_radix(hex, 16) { + // Check if this is AABBGGRR (8 hex digits) or BBGGRR (6 hex digits) + let (alpha, bgr_value) = if hex.len() >= 8 { + // Full AABBGGRR format + let alpha = ((value >> 24) & 0xFF) as u8; + // Convert ASS alpha (00=opaque, FF=transparent) to RGBA (00=transparent, FF=opaque) + let rgba_alpha = 255 - alpha; + (rgba_alpha, value & 0xFFFFFF) + } else { + // Legacy BBGGRR format without alpha, assume opaque + (255u8, value) + }; + + // Extract colors in BGR order + let r = (bgr_value & 0xFF) as u8; // Last 2 hex digits = Red + let g = ((bgr_value >> 8) & 0xFF) as u8; // Middle 2 hex digits = Green + let b = ((bgr_value >> 16) & 0xFF) as u8; // First 2 hex digits = Blue + + // Return in RGBA order for rendering + return [r, g, b, alpha]; + } + } + [255, 255, 255, 255] // Default white, opaque + } + + /// Parse alpha from ASS format + #[allow(dead_code)] // Utility for parsing ASS alpha values + fn parse_ass_alpha(alpha: &str) -> u8 { + if let Some(hex) = alpha.strip_prefix("&H") { + if let Ok(value) = u8::from_str_radix(hex, 16) { + // ASS alpha is inverted (00 = opaque, FF = transparent) + return 255 - value; + } + } + 255 // Default opaque + } + + fn process_event( + &mut self, + event: &Event, + time_cs: u32, + context: &RenderContext, + ) -> Result, RenderError> { + debug_println!( + "PROCESS_EVENT: Processing event with text: '{}'", + event.text + ); + // Get text segments with their individual tags + let segments = segment_text_with_tags(event.text, None)?; + + let segment_count = segments.len(); + debug_println!("PROCESS_EVENT: Got {segment_count} segments"); + for (i, seg) in segments.iter().enumerate() { + debug_println!( + " Segment {}: text='{}', drawing_mode={:?}", + i, + seg.text, + seg.tags.drawing_mode + ); + } + + if segments.is_empty() { + return Ok(Vec::new()); + } + + // Check if this is a drawing command + if let Some(draw_level) = segments[0].tags.drawing_mode { + debug_println!( + "DRAWING: Found drawing mode level {} for text: '{}'", + draw_level, + segments[0].text.chars().take(50).collect::() + ); + + if draw_level > 0 { + // Clone the style to avoid borrow issues + let style_cloned = self + .styles_map + .get(event.style) + .or(self.default_style.as_ref()) + .cloned(); + + debug_println!("DRAWING: Processing drawing command"); + + return self.process_drawing_command( + &segments[0], + event, + style_cloned.as_ref(), + time_cs, + context, + ); + } + } + + // Clone the style to avoid borrow issues + let style_cloned = self + .styles_map + .get(event.style) + .or(self.default_style.as_ref()) + .cloned(); + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if event.text.contains("Чысценькая") { + debug_println!( + " Style '{}' found: {}", + event.style, + style_cloned.is_some() + ); + if let Some(ref style) = style_cloned { + debug_println!(" Font: {}, Size: {}", style.fontname, style.fontsize); + } + } + + // Process text segments with proper style inheritance + self.process_text_segments(segments, event, style_cloned.as_ref(), time_cs, context) + } + + fn process_drawing_command( + &mut self, + segment: &TextSegment, + _event: &Event, + style: Option<&OwnedStyle>, + time_cs: u32, + context: &RenderContext, + ) -> Result, RenderError> { + let plain_text = &segment.text; + let tags = &segment.tags; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + "DRAWING: process_drawing_command called with text: '{}'", + plain_text + ); + + // Try to get drawing path from cache + let draw_cache_key = crate::cache::DrawingCacheKey { + commands: plain_text.clone(), + }; + + let path_opt = if let Some(cached) = self.cache.get_drawing_path(&draw_cache_key) { + debug_println!("DRAWING: Got cached path"); + cached + } else { + let path = process_drawing_commands(plain_text)?; + debug_println!( + "DRAWING: Parsed drawing commands, got path: {}", + path.is_some() + ); + self.cache.store_drawing_path(draw_cache_key, path.clone()); + path + }; + + if let Some(path) = path_opt { + debug_println!("DRAWING: Creating VectorData layer"); + // Get color from tags or style + let color = if let Some(c) = tags.colors.primary { + c + } else if let Some(s) = style { + Self::parse_ass_color(&s.primary_colour) + } else { + [255, 255, 255, 255] + }; + + // Calculate scaling factors + let scale_x = context.width() as f32 / self.play_res_x; + let scale_y = context.height() as f32 / self.play_res_y; + + // Calculate position with proper scaling + let (x, y) = if let Some((px, py)) = tags.position { + // Scale from script coordinates to render coordinates + (px * scale_x, py * scale_y) + } else if let Some((x1, y1, x2, y2, t1, t2)) = tags.movement { + // Movement times are relative to event start + let event_start_cs = _event.start_time_cs().unwrap_or(0); + let event_end_cs = _event.end_time_cs().unwrap_or(u32::MAX); + + // If t1 and t2 are both 0, movement spans the entire event duration + let (move_start_cs, move_end_cs) = if t1 == 0 && t2 == 0 { + (event_start_cs, event_end_cs) + } else { + (event_start_cs + t1, event_start_cs + t2) + }; + + let progress = calculate_move_progress(time_cs, move_start_cs, move_end_cs); + let x = x1 + (x2 - x1) * progress; + let y = y1 + (y2 - y1) * progress; + // Scale from script coordinates to render coordinates + (x * scale_x, y * scale_y) + } else { + // Default center (in render coordinates) + (context.width() as f32 / 2.0, context.height() as f32 / 2.0) + }; + + // Get path bounds to calculate proper alignment offset + let bounds = path.bounds(); + + // Get alignment from tags or style (default to 5 = center) + let alignment = tags + .formatting + .alignment + .map(|a| a as i32) + .or(style.and_then(|s| { + // Parse alignment from style - it's stored as a string like "5" + s.alignment.parse::().ok() + })) + .unwrap_or(5); + + // Calculate alignment offset based on path bounds + // Alignment uses numpad layout: 1-3 bottom, 4-6 middle, 7-9 top + let (align_x_offset, align_y_offset) = { + // For alignment, we need to position the bounding box + // The \pos coordinate should be where the alignment point ends up + + // Horizontal alignment: 1,4,7 = left, 2,5,8 = center, 3,6,9 = right + let x_offset = match alignment % 3 { + 1 => -bounds.left(), // Left align: move left edge to pos + 2 => -(bounds.left() + bounds.right()) / 2.0, // Center align: move center to pos + 0 => -bounds.right(), // Right align: move right edge to pos + _ => -(bounds.left() + bounds.right()) / 2.0, // Default center + }; + + // Vertical alignment: 1,2,3 = bottom, 4,5,6 = middle, 7,8,9 = top + let y_offset = match alignment { + 1..=3 => -bounds.bottom(), // Bottom align: move bottom edge to pos + 4..=6 => -(bounds.top() + bounds.bottom()) / 2.0, // Middle align: move center to pos + 7..=9 => -bounds.top(), // Top align: move top edge to pos + _ => -(bounds.top() + bounds.bottom()) / 2.0, // Default middle + }; + + (x_offset, y_offset) + }; + + // Apply transform to path with alignment offset + let transformed_path = path.transform(Transform::from_translate( + x + align_x_offset, + y + align_y_offset, + )); + + debug_println!( + "DRAWING: Alignment={}, offset=({:.2}, {:.2}), final pos=({:.2}, {:.2})", + alignment, + align_x_offset, + align_y_offset, + x + align_x_offset, + y + align_y_offset + ); + debug_println!( + "DRAWING: Created transformed path, returning VectorData with color {:?}", + color + ); + + return Ok(vec![IntermediateLayer::Vector(VectorData { + path: transformed_path, + color, + stroke: None, + bounds: None, + })]); + } + + Ok(Vec::new()) + } + + fn process_text_segments( + &mut self, + segments: Vec, + event: &Event, + style: Option<&OwnedStyle>, + time_cs: u32, + context: &RenderContext, + ) -> Result, RenderError> { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if event.text.contains("Чысценькая") { + let segment_count = segments.len(); + debug_println!(" process_text_segments: {segment_count} segments"); + for (i, seg) in segments.iter().enumerate() { + debug_println!( + " Segment {}: text='{}', tags={:?}", + i, + seg.text, + seg.tags.position + ); + } + } + + let mut all_layers = Vec::new(); + let mut line_height = 48.0; // Default from styles + + // Calculate scaling factors for sizes + let scale_x = context.width() as f32 / self.play_res_x; + let scale_y = context.height() as f32 / self.play_res_y; + + // Get base style properties + let default_font_name = style.map(|s| s.fontname.as_ref()).unwrap_or("Arial"); + let default_font_size_base = style + .map(|s| s.fontsize.parse::().unwrap_or(48.0)) + .unwrap_or(48.0); + // Font sizes in ASS are already in the script resolution coordinate system + // They need to be scaled according to the PlayResY to output resolution ratio + // This matches libass behavior + // Also apply DPI scale to match libass (72 DPI vs 96 DPI) + let _default_font_size = default_font_size_base * scale_y * self.dpi_scale; + // In ASS format: -1 = true (bold/italic), 0 = false + let default_bold = style.map(|s| s.bold == "-1").unwrap_or(false); + let default_italic = style.map(|s| s.italic == "-1").unwrap_or(false); + let default_underline = style.map(|s| s.underline == "-1").unwrap_or(false); + let default_strikeout = style.map(|s| s.strikeout == "-1").unwrap_or(false); + + // Parse style colors + let default_primary_color = style + .map(|s| Self::parse_ass_color(&s.primary_colour)) + .unwrap_or([255, 255, 255, 255]); + let default_secondary_color = style + .map(|s| Self::parse_ass_color(&s.secondary_colour)) + .unwrap_or([255, 0, 0, 255]); + let default_outline_color = style + .map(|s| Self::parse_ass_color(&s.outline_colour)) + .unwrap_or([0, 0, 0, 255]); + let default_back_color = style + .map(|s| Self::parse_ass_color(&s.back_colour)) + .unwrap_or([0, 0, 0, 128]); + + // Parse style formatting values and scale them only if ScaledBorderAndShadow is enabled + let default_outline_base = style + .map(|s| s.outline.parse::().unwrap_or(2.0)) + .unwrap_or(2.0); + let default_shadow_base = style + .map(|s| s.shadow.parse::().unwrap_or(2.0)) + .unwrap_or(2.0); + // Scale outline and shadow from script coordinates only if ScaledBorderAndShadow is true + let default_outline = if self.scaled_border_and_shadow { + default_outline_base * scale_y + } else { + default_outline_base + }; + let default_shadow = if self.scaled_border_and_shadow { + default_shadow_base * scale_y + } else { + default_shadow_base + }; + let default_scale_x = style + .map(|s| s.scale_x.parse::().unwrap_or(100.0)) + .unwrap_or(100.0); + let default_scale_y = style + .map(|s| s.scale_y.parse::().unwrap_or(100.0)) + .unwrap_or(100.0); + let default_spacing = style + .map(|s| s.spacing.parse::().unwrap_or(0.0)) + .unwrap_or(0.0); + let default_alignment = style + .map(|s| s.alignment.parse::().unwrap_or(2)) + .unwrap_or(2); + + // Get base position from first segment - we'll adjust per-segment as needed + let _base_tags = &segments[0].tags; + + // Restructure segments into logical lines + let mut logical_lines: Vec> = Vec::new(); + let mut current_line_segments: Vec = Vec::new(); + + for segment in segments { + if segment.text.is_empty() { + continue; + } + + // Check for newlines + if segment.text.contains('\n') { + let parts: Vec<&str> = segment.text.split('\n').collect(); + for (i, part) in parts.iter().enumerate() { + if i > 0 && !current_line_segments.is_empty() { + logical_lines.push(current_line_segments.clone()); + current_line_segments.clear(); + } + + if !part.is_empty() { + let sub_segment = TextSegment { + text: part.to_string(), + start: segment.start, + end: segment.end, + tags: segment.tags.clone(), + }; + current_line_segments.push(sub_segment); + } + } + } else { + current_line_segments.push(segment); + } + } + + // Add the last line + if !current_line_segments.is_empty() { + logical_lines.push(current_line_segments); + } + + // Calculate total height for multi-line text + let num_lines = logical_lines.len(); + // For line height in libass: + // - Line spacing uses the font size in script resolution scaled to render resolution + // - DPI scaling affects glyph rendering but NOT line spacing + // - ScaleY affects glyph size but NOT line spacing + let line_spacing_multiplier = 1.0; // Match libass line spacing + + // Line height for spacing between lines (not affected by DPI scale in libass) + let estimated_line_height = default_font_size_base * scale_y; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("Line height calculation: font_size_base={}, scale_y={}, dpi_scale={}, estimated_line_height={}", + default_font_size_base, scale_y, self.dpi_scale, estimated_line_height); + let _total_text_height = estimated_line_height * num_lines as f32 * line_spacing_multiplier; + + // Process each logical line + let mut line_y_offset = 0.0; + + for (line_index, line_segments) in logical_lines.into_iter().enumerate() { + let mut current_x = 0.0; + let mut needs_initial_position = true; + let mut karaoke_accumulated_time = 0u32; // Track cumulative karaoke time for this line + + for segment in line_segments { + let mut tags = segment.tags.clone(); + let line_text = &segment.text; + + // Debug: Check what's in tags + if line_text.contains("m ") || line_text.contains("l ") { + debug_println!( + "DRAWING DEBUG: Found potential drawing text: '{}', drawing_mode: {:?}", + line_text, + tags.drawing_mode + ); + } + + // Check if this segment is in drawing mode + if let Some(drawing_mode) = tags.drawing_mode { + if drawing_mode > 0 { + debug_println!( + "DRAWING: Processing drawing segment with mode {} and commands: {}", + drawing_mode, + line_text + ); + + // Process drawing commands + if let Ok(drawing_layers) = + self.process_drawing_command(&segment, event, style, time_cs, context) + { + for layer in drawing_layers { + all_layers.push(layer); + } + } + continue; // Skip text processing for drawing segments + } + } + + // Apply transform animations if present + let event_start_cs = event.start_time_cs().unwrap_or(0); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if !tags.transforms.is_empty() { + debug_println!( + "TRANSFORM: Found {} transform(s) for text segment at time {}cs", + tags.transforms.len(), + time_cs + ); + } + let default_colors = ( + default_primary_color, + default_secondary_color, + default_outline_color, + default_back_color, + ); + self.apply_transform_animations(&mut tags, event_start_cs, time_cs, default_colors); + + // Shape the text first to get dimensions for proper alignment + // Get base font size and scale factors + let base_font_size = tags.font.size.unwrap_or(default_font_size_base); // Use unscaled base size + let font_scale_x = tags.font.scale_x.unwrap_or(default_scale_x) / 100.0; + let font_scale_y = tags.font.scale_y.unwrap_or(default_scale_y) / 100.0; + // Apply both resolution scaling, percentage scaling, and DPI scaling + let actual_font_size = base_font_size * scale_y * font_scale_y * self.dpi_scale; + + let font_to_use = tags.font.name.as_deref().unwrap_or(default_font_name); + + let shaped = shape_text_with_style( + line_text, + font_to_use, + actual_font_size, + tags.formatting.bold.unwrap_or(default_bold), + tags.formatting.italic.unwrap_or(default_italic), + &self.font_database, + )?; + + // Calculate position for this segment + let (segment_x, segment_y) = if tags.position.is_some() || tags.movement.is_some() { + // Explicit position override - use it directly + let (anchor_x, anchor_y) = self.calculate_position_from_tags( + &tags, + event, + context, + time_cs, + default_alignment, + ); + + // Apply alignment-based offset to get top-left corner + let alignment = tags.formatting.alignment.unwrap_or(default_alignment); + needs_initial_position = false; + let (x, y) = self.apply_alignment_offset( + anchor_x, + anchor_y, + shaped.width, + shaped.height, + alignment, + ); + + (x, y) + } else if needs_initial_position || tags.formatting.alignment.is_some() { + // Need to calculate initial position or alignment changed + let alignment = tags.formatting.alignment.unwrap_or(default_alignment); + let (anchor_x, anchor_y) = + self.calculate_position_from_alignment(alignment, event, context); + + // For multi-line text, adjust positioning based on alignment + let adjusted_y = if num_lines > 1 { + match alignment { + 1..=3 => { + // Bottom alignment: stack lines upward from bottom + // First line is at the bottom, subsequent lines above it + let line_offset = (num_lines - 1 - line_index) as f32 + * estimated_line_height + * line_spacing_multiplier; + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("Multi-line bottom align: line {} of {}, anchor_y={}, line_offset={}, estimated_line_height={}", + line_index + 1, num_lines, anchor_y, line_offset, estimated_line_height); + anchor_y - line_offset + } + 7..=9 => { + // Top alignment: stack lines downward from top + anchor_y + line_y_offset + } + _ => { + // Middle alignment: center the block vertically + let block_offset = -((num_lines as f32 - 1.0) + * estimated_line_height + * line_spacing_multiplier + / 2.0); + anchor_y + block_offset + line_y_offset + } + } + } else { + anchor_y + }; + + // For multi-line text, use the line height for vertical positioning + // to ensure proper spacing between lines + let height_for_positioning = if num_lines > 1 { + estimated_line_height + } else { + shaped.height + }; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("Calling apply_alignment_offset: adjusted_y={}, height_for_positioning={} (shaped.height={})", + adjusted_y, height_for_positioning, shaped.height); + + let (x, y) = self.apply_alignment_offset( + anchor_x, + adjusted_y, + shaped.width, + height_for_positioning, + alignment, + ); + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("Result from apply_alignment_offset: y={}", y); + current_x = x + shaped.width; // Set position for next segment + needs_initial_position = false; + (x, y) + } else { + // Continue from previous segment position + // Need to maintain the absolute Y position from the previous segment + // For now, calculate it based on alignment and line offset + let alignment = tags.formatting.alignment.unwrap_or(default_alignment); + let (anchor_x, anchor_y) = + self.calculate_position_from_alignment(alignment, event, context); + + let adjusted_y = if num_lines > 1 { + match alignment { + 1..=3 => { + // Bottom alignment: stack lines upward from bottom + let line_offset = (num_lines - 1 - line_index) as f32 + * estimated_line_height + * line_spacing_multiplier; + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("Multi-line bottom align (else branch): line {} of {}, anchor_y={}, line_offset={}, estimated_line_height={}", + line_index + 1, num_lines, anchor_y, line_offset, estimated_line_height); + anchor_y - line_offset + } + 7..=9 => { + // Top alignment: stack lines downward from top + anchor_y + line_y_offset + } + _ => { + // Middle alignment: center the block vertically + let block_offset = -((num_lines as f32 - 1.0) + * estimated_line_height + * line_spacing_multiplier + / 2.0); + anchor_y + block_offset + line_y_offset + } + } + } else { + anchor_y + }; + + // For multi-line text, use the line height for vertical positioning + let height_for_positioning = if num_lines > 1 { + estimated_line_height + } else { + shaped.height + }; + + let (_, y) = self.apply_alignment_offset( + anchor_x, + adjusted_y, + shaped.width, + height_for_positioning, + alignment, + ); + (current_x, y) + }; + + // Get font information with proper inheritance + let font_family = tags.font.name.as_deref().unwrap_or(default_font_name); + // Font size was already calculated above for shaping + // Use the same value here for consistency + let font_size = actual_font_size; + line_height = font_size; + + // Get formatting with inheritance from style + let bold = tags.formatting.bold.unwrap_or(default_bold); + let italic = tags.formatting.italic.unwrap_or(default_italic); + let underline = tags.formatting.underline.unwrap_or(default_underline); + let strikeout = tags.formatting.strikeout.unwrap_or(default_strikeout); + + // Note: 'shaped' was already created above for alignment calculation + + // Get colors with proper inheritance + let mut color = tags.colors.primary.unwrap_or(default_primary_color); + let mut outline_color = tags.colors.outline.unwrap_or(default_outline_color); + let mut shadow_color = tags.colors.shadow.unwrap_or(default_back_color); + + // Debug: Print color being used for text + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if !line_text.is_empty() { + debug_println!( + "Text '{}' using color: R={}, G={}, B={}, A={}", + line_text, + color[0], + color[1], + color[2], + color[3] + ); + if let Some(s) = style { + let primary_colour = &s.primary_colour; + debug_println!(" Style primary_colour: {primary_colour}"); + } + } + + // Apply individual alpha overrides (ASS alpha is inverted: 00=opaque, FF=transparent) + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + "Alpha values: alpha={:?}, alpha1={:?}, alpha3={:?}, alpha4={:?}", + tags.colors.alpha, + tags.colors.alpha1, + tags.colors.alpha3, + tags.colors.alpha4 + ); + + if let Some(alpha) = tags.colors.alpha1.or(tags.colors.alpha) { + // Alpha is already inverted in parse_alpha (255 = opaque, 0 = transparent) + color[3] = alpha; + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("Applied alpha to primary color: {alpha}"); + } + if let Some(alpha) = tags.colors.alpha3 { + // Alpha is already inverted in parse_alpha + outline_color[3] = alpha; + } + if let Some(alpha) = tags.colors.alpha4 { + // Alpha is already inverted in parse_alpha + shadow_color[3] = alpha; + } + + // Apply fade effect + if let Some(fade) = &tags.fade { + // For \fad(t1,t2), t1 is fade-in duration, t2 is fade-out duration + // Calculate actual fade times relative to event + let event_start = event.start_time_cs().unwrap_or(0); + let event_end = event.end_time_cs().unwrap_or(u32::MAX); + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + "FADE DEBUG: Found fade tag for '{}' at time_cs={}", + line_text.chars().take(30).collect::(), + time_cs + ); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!(" Event times: start={}, end={}", event_start, event_end); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!(" Fade params: time_start={}, time_end={}, alpha_start={}, alpha_end={}, alpha_middle={:?}", + fade.time_start, fade.time_end, fade.alpha_start, fade.alpha_end, fade.alpha_middle); + + let fade_alpha = if fade.alpha_middle.is_some() { + // Complex fade with 7 parameters - times are absolute + let fade_progress = + calculate_fade_progress(time_cs, fade.time_start, fade.time_end); + // Interpolate between ASS alpha values (00=opaque, FF=transparent) + let ass_alpha = fade.alpha_start as f32 + + (fade.alpha_end as f32 - fade.alpha_start as f32) * fade_progress; + // Convert to RGBA alpha (00=transparent, FF=opaque) + let result = 255.0 - ass_alpha; + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + " Complex fade: progress={:.2}, ass_alpha={:.1}, rgba_alpha={:.1}", + fade_progress, + ass_alpha, + result + ); + result + } else { + // Simple fade - times are durations + let fade_in_end = event_start + fade.time_start; + let fade_out_start = event_end.saturating_sub(fade.time_end); + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + " Simple fade: fade_in_end={}, fade_out_start={}", + fade_in_end, + fade_out_start + ); + + if time_cs < fade_in_end { + // During fade in + let progress = (time_cs.saturating_sub(event_start)) as f32 + / fade.time_start.max(1) as f32; + let alpha = 255.0 * progress.min(1.0); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + " FADE IN: progress={:.2}, alpha={:.1}", + progress, + alpha + ); + alpha + } else if time_cs >= fade_out_start && fade_out_start < event_end { + // During fade out + let progress = (event_end.saturating_sub(time_cs)) as f32 + / fade.time_end.max(1) as f32; + let alpha = 255.0 * progress.min(1.0); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + " FADE OUT: progress={:.2}, alpha={:.1}", + progress, + alpha + ); + alpha + } else { + // Fully visible + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!(" FULLY VISIBLE: alpha=255.0"); + 255.0 + } + }; + + // Apply fade to all color components (primary, outline, shadow) + let fade_factor = fade_alpha / 255.0; + + let _old_alpha = color[3]; + color[3] = (color[3] as f32 * fade_factor) as u8; + outline_color[3] = (outline_color[3] as f32 * fade_factor) as u8; + shadow_color[3] = (shadow_color[3] as f32 * fade_factor) as u8; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!(" FADE APPLIED: fade_alpha={:.1}, primary: {}→{}, outline: {}→{}, shadow: {}→{}", + fade_alpha, _old_alpha, color[3], + (outline_color[3] as f32 / fade_factor) as u8, outline_color[3], + (shadow_color[3] as f32 / fade_factor) as u8, shadow_color[3]); + } + + // Get spacing value (from tags or default style) + let spacing = tags.font.spacing.unwrap_or(default_spacing); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if spacing != 0.0 || tags.font.spacing.is_some() { + debug_println!("DEBUG: tags.font.spacing={:?}, default_spacing={}, final spacing={} for text '{}'", + tags.font.spacing, default_spacing, spacing, line_text); + } + + // Create text layer + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if !(-100.0..=1080.0).contains(&segment_y) { + debug_println!( + "WARNING: Text positioned off-screen at Y={} for text '{}'", + segment_y, + line_text + ); + debug_println!( + " Font size: {}, ScaleY: {}", + font_size, + tags.font.scale_y.unwrap_or(default_scale_y) + ); + } + + let mut layer = TextData { + text: line_text.to_string(), + font_family: font_family.to_string(), + font_size, + color, + x: segment_x, + y: segment_y, + effects: SmallVec::new(), + spacing, + }; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if event.text.contains("Чысценькая") { + debug_println!(" Layer created: pos=({:.1}, {:.1}), font_size={:.1}, color={:?}, text='{}'", + segment_x, segment_y, font_size, color, line_text); + } + + // Add effects + if bold { + layer.effects.push(TextEffect::Bold); + } + if italic { + layer.effects.push(TextEffect::Italic); + } + if underline { + layer.effects.push(TextEffect::Underline); + } + if strikeout { + layer.effects.push(TextEffect::Strikethrough); + } + + // Add outline effect with per-axis border support + // Scale tag values only if ScaledBorderAndShadow is enabled + let outline_width_x = if self.scaled_border_and_shadow { + tags.formatting + .border_x + .map(|w| w * scale_x) + .or(tags.formatting.border.map(|w| w * scale_y)) + .unwrap_or(default_outline) + } else { + tags.formatting + .border_x + .or(tags.formatting.border) + .unwrap_or(default_outline) + }; + let outline_width_y = if self.scaled_border_and_shadow { + tags.formatting + .border_y + .map(|w| w * scale_y) + .or(tags.formatting.border.map(|w| w * scale_y)) + .unwrap_or(default_outline) + } else { + tags.formatting + .border_y + .or(tags.formatting.border) + .unwrap_or(default_outline) + }; + if outline_width_x > 0.0 || outline_width_y > 0.0 { + layer.effects.push(TextEffect::Outline { + color: outline_color, + width: outline_width_x.max(outline_width_y), // Use max for now + }); + } + + // Add shadow effect with per-axis shadow support + // Scale tag values only if ScaledBorderAndShadow is enabled + let shadow_x = if self.scaled_border_and_shadow { + tags.formatting + .shadow_x + .map(|s| s * scale_x) + .or(tags.formatting.shadow.map(|s| s * scale_y)) + .unwrap_or(default_shadow) + } else { + tags.formatting + .shadow_x + .or(tags.formatting.shadow) + .unwrap_or(default_shadow) + }; + let shadow_y = if self.scaled_border_and_shadow { + tags.formatting + .shadow_y + .map(|s| s * scale_y) + .or(tags.formatting.shadow.map(|s| s * scale_y)) + .unwrap_or(default_shadow) + } else { + tags.formatting + .shadow_y + .or(tags.formatting.shadow) + .unwrap_or(default_shadow) + }; + if shadow_x != 0.0 || shadow_y != 0.0 { + layer.effects.push(TextEffect::Shadow { + color: shadow_color, + x_offset: shadow_x * 0.5, // Further reduce shadow to match libass + y_offset: shadow_y * 0.5, + }); + } + + // Add blur effect - handle both blur and edge blur + let blur_radius = tags.formatting.blur.unwrap_or(0.0); + let edge_blur = tags.formatting.blur_edges.unwrap_or(0.0); + if blur_radius > 0.0 { + layer.effects.push(TextEffect::Blur { + radius: blur_radius, + }); + } + if edge_blur > 0.0 { + // Edge blur only affects the outline + layer + .effects + .push(TextEffect::EdgeBlur { radius: edge_blur }); + } + + // Add rotation effects if present + let rotation_x = tags.font.rotation_x.unwrap_or(0.0); + let rotation_y = tags.font.rotation_y.unwrap_or(0.0); + let rotation_z = tags.font.rotation_z.or(tags.font.angle).unwrap_or(0.0); + if rotation_x != 0.0 || rotation_y != 0.0 || rotation_z != 0.0 { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + "ROTATION: Adding rotation effect - x={}, y={}, z={} for text '{}'", + rotation_x, + rotation_y, + rotation_z, + line_text + ); + layer.effects.push(TextEffect::Rotation { + x: rotation_x, + y: rotation_y, + z: rotation_z, + }); + } + + // Add shear effects if present + if let Some(shear_x) = tags.shear_x { + if shear_x != 0.0 || tags.shear_y.unwrap_or(0.0) != 0.0 { + layer.effects.push(TextEffect::Shear { + x: shear_x, + y: tags.shear_y.unwrap_or(0.0), + }); + } + } + + // Add scale effect if X-scale is not 100% + // Y-scale is already applied to font size during shaping, + // but X-scale needs to be applied as a transform + let font_scale_x_val = tags.font.scale_x.unwrap_or(default_scale_x); + let font_scale_y_val = tags.font.scale_y.unwrap_or(default_scale_y); + if (font_scale_x_val - 100.0).abs() > 0.01 { + // Add scale effect to handle X-scaling + layer.effects.push(TextEffect::Scale { + x: font_scale_x_val, + y: font_scale_y_val, + }); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + "SCALE EFFECT ADDED: x={}, y={} for text '{}'", + font_scale_x_val, + font_scale_y_val, + line_text + ); + } + + // Add clip region if present (scale from script coordinates) + if let Some(clip) = &tags.clip { + layer.effects.push(TextEffect::Clip { + x1: clip.x1 * scale_x, + y1: clip.y1 * scale_y, + x2: clip.x2 * scale_x, + y2: clip.y2 * scale_y, + inverse: clip.inverse, + }); + } + + // Handle baseline offset + if let Some(baseline_offset) = tags.baseline_offset { + layer.y += baseline_offset; + } + + // Handle karaoke - track per-syllable timing + if let Some(karaoke) = &tags.karaoke { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + "KARAOKE FOUND: duration={}, style={:?}, time_cs={}, event_start={}", + karaoke.duration, + karaoke.style, + time_cs, + event.start_time_cs().unwrap_or(0) + ); + + // Calculate progress for THIS syllable based on cumulative timing + let syllable_start = + event.start_time_cs().unwrap_or(0) + karaoke_accumulated_time; + let syllable_end = syllable_start + karaoke.duration; + + let progress = if time_cs < syllable_start { + 0.0 // Not yet started + } else if time_cs >= syllable_end { + 1.0 // Fully highlighted + } else { + // In progress + (time_cs - syllable_start) as f32 / karaoke.duration as f32 + }; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("KARAOKE TIMING: syllable_start={}, syllable_end={}, time_cs={}, progress={}", + syllable_start, syllable_end, time_cs, progress); + + // Add karaoke effect with correct progress + layer.effects.push(TextEffect::Karaoke { + progress, + style: match karaoke.style { + KaraokeStyle::Basic => 0, + KaraokeStyle::Fill => 1, + KaraokeStyle::Outline => 2, + KaraokeStyle::Sweep => 3, + }, + }); + + // Accumulate time for next syllable + karaoke_accumulated_time += karaoke.duration; + } + + // Update x position for next segment (only if no explicit positioning was used) + if tags.position.is_none() + && tags.movement.is_none() + && tags.formatting.alignment.is_none() + { + if let Some(advance) = shaped.total_advance() { + current_x += advance * font_scale_x; + } + } else { + current_x += line_text.len() as f32 * font_size * 0.6 * font_scale_x; + } + + all_layers.push(IntermediateLayer::Text(layer)); + } + + // Move to next line (using same multiplier as above) + line_y_offset += line_height * line_spacing_multiplier; + } + + Ok(all_layers) + } + + fn calculate_position_from_tags( + &self, + tags: &ProcessedTags, + event: &Event, + context: &RenderContext, + time_cs: u32, + default_alignment: u8, + ) -> (f32, f32) { + // Calculate scaling factors + let scale_x = context.width() as f32 / self.play_res_x; + let scale_y = context.height() as f32 / self.play_res_y; + + // Check for explicit position + if let Some((mut px, mut py)) = tags.position { + // If LayoutRes is present, positions are in LayoutRes coordinates + // and need to be scaled to PlayRes first + if let (Some(layout_x), Some(layout_y)) = (self.layout_res_x, self.layout_res_y) { + px *= self.play_res_x / layout_x; + py *= self.play_res_y / layout_y; + } + // Then scale from script (PlayRes) coordinates to render coordinates + return (px * scale_x, py * scale_y); + } + + // Check for movement + if let Some((mut x1, mut y1, mut x2, mut y2, t1, t2)) = tags.movement { + // If LayoutRes is present, movement coordinates are in LayoutRes coordinates + if let (Some(layout_x), Some(layout_y)) = (self.layout_res_x, self.layout_res_y) { + x1 *= self.play_res_x / layout_x; + y1 *= self.play_res_y / layout_y; + x2 *= self.play_res_x / layout_x; + y2 *= self.play_res_y / layout_y; + } + + // Movement times are relative to event start + let event_start_cs = event.start_time_cs().unwrap_or(0); + let event_end_cs = event.end_time_cs().unwrap_or(u32::MAX); + + // t1 and t2 are in milliseconds, need to convert to centiseconds + let t1_cs = t1 / 10; + let t2_cs = t2 / 10; + + // If t1 and t2 are both 0, movement spans the entire event duration + let (move_start_cs, move_end_cs) = if t1 == 0 && t2 == 0 { + (event_start_cs, event_end_cs) + } else { + (event_start_cs + t1_cs, event_start_cs + t2_cs) + }; + + #[cfg(debug_assertions)] + { + debug_println!( + "MOVE DEBUG: x1={}, y1={}, x2={}, y2={}, t1={}, t2={}", + x1, + y1, + x2, + y2, + t1, + t2 + ); + debug_println!( + " event_start_cs={}, move_start_cs={}, move_end_cs={}, time_cs={}", + event_start_cs, + move_start_cs, + move_end_cs, + time_cs + ); + } + + let progress = calculate_move_progress(time_cs, move_start_cs, move_end_cs); + let x = x1 + (x2 - x1) * progress; + let y = y1 + (y2 - y1) * progress; + + #[cfg(debug_assertions)] + debug_println!( + " progress={}, calculated x={}, y={}, scale_x={}, scale_y={}", + progress, + x, + y, + scale_x, + scale_y + ); + + // Scale from script (PlayRes) coordinates to render coordinates + return (x * scale_x, y * scale_y); + } + + // Calculate based on alignment + let alignment = tags.formatting.alignment.unwrap_or(default_alignment); + self.calculate_position_from_alignment(alignment, event, context) + } + + fn calculate_position_from_alignment( + &self, + alignment: u8, + event: &Event, + context: &RenderContext, + ) -> (f32, f32) { + let width = context.width() as f32; + let height = context.height() as f32; + + // Calculate scaling factors for margins + let scale_x = width / self.play_res_x; + let scale_y = height / self.play_res_y; + + // Parse margins - use style margins if event margins are 0 or empty + // Get margins in script coordinates first + let style_margin_l = self + .styles_map + .get(event.style) + .and_then(|s| s.margin_l.parse::().ok()) + .unwrap_or(0.0); + let style_margin_r = self + .styles_map + .get(event.style) + .and_then(|s| s.margin_r.parse::().ok()) + .unwrap_or(0.0); + let style_margin_v = self + .styles_map + .get(event.style) + .and_then(|s| s.margin_v.parse::().ok()) + .unwrap_or(0.0); + + // Get margins in script coordinates + let margin_l_script = if event.margin_l.is_empty() || event.margin_l == "0" { + style_margin_l + } else { + event.margin_l.parse::().unwrap_or(style_margin_l) + }; + let margin_r_script = if event.margin_r.is_empty() || event.margin_r == "0" { + style_margin_r + } else { + event.margin_r.parse::().unwrap_or(style_margin_r) + }; + let margin_v_script = if event.margin_v.is_empty() || event.margin_v == "0" { + style_margin_v + } else { + event.margin_v.parse::().unwrap_or(style_margin_v) + }; + + // Scale margins to screen coordinates + let margin_l = margin_l_script * scale_x; + let margin_r = margin_r_script * scale_x; + let _margin_v = margin_v_script * scale_y; + + // ASS alignment uses numpad layout + // SubStation numpad-style alignment: + // 7 8 9 (top-left, top-center, top-right) + // 4 5 6 (middle-left, middle-center, middle-right) + // 1 2 3 (bottom-left, bottom-center, bottom-right) + + // For legacy alignment (\a tag), map to numpad: + // 1-3: bottom, 4-6: unused, 7-9: top, 10-12: middle + let mapped_alignment = if alignment > 9 { + // Legacy alignment mapping + match alignment { + 10 => 4, // Left middle + 11 => 5, // Center middle + 12 => 6, // Right middle + _ => alignment, + } + } else { + alignment + }; + + // Return the anchor point based on alignment and margins + // This is where the aligned point of the text box should be placed + // ASS alignment uses numpad layout: 1,4,7 = left; 2,5,8 = center; 3,6,9 = right + let x = match mapped_alignment { + 1 | 4 | 7 => margin_l, // Left column + 2 | 5 | 8 => width / 2.0, // Center column + 3 | 6 | 9 => width - margin_r, // Right column + _ => width / 2.0, // Default center + }; + + // Position calculation following libass approach + // libass uses: (PlayResY - MarginV) * scale_y for bottom alignment + // Calculate position in script coordinates first, then scale to screen + let y_script = match mapped_alignment { + 1..=3 => self.play_res_y - margin_v_script, // Bottom row: PlayResY - MarginV + 4..=6 => self.play_res_y / 2.0, // Middle row: PlayResY / 2 + 7..=9 => margin_v_script, // Top row: MarginV + _ => self.play_res_y - margin_v_script, // Default bottom + }; + + // Transform from script coordinates to screen coordinates + let y = y_script * scale_y; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("calculate_position_from_alignment: alignment={}, margins=(L:{:.1}, R:{:.1}, V:{:.1}), screen={}x{} -> anchor=({:.1}, {:.1})", + mapped_alignment, margin_l, margin_r, _margin_v, width as u32, height as u32, x, y); + + (x, y) + } + + /// Apply alignment offset to convert from anchor point to top-left corner + /// Takes the anchor point and text dimensions, returns top-left corner for rendering + fn apply_alignment_offset( + &self, + anchor_x: f32, + anchor_y: f32, + text_width: f32, + text_height: f32, + alignment: u8, + ) -> (f32, f32) { + // Map legacy alignment if needed + let mapped_alignment = if alignment > 9 { + match alignment { + 10 => 4, + 11 => 5, + 12 => 6, + _ => alignment, + } + } else { + alignment + }; + + // Calculate horizontal offset based on alignment + // ASS alignment uses numpad layout: 1,4,7 = left; 2,5,8 = center; 3,6,9 = right + let x = match mapped_alignment { + 1 | 4 | 7 => anchor_x, // Left-aligned: anchor is left edge + 2 | 5 | 8 => anchor_x - text_width / 2.0, // Center-aligned: anchor is center + 3 | 6 | 9 => anchor_x - text_width, // Right-aligned: anchor is right edge + _ => anchor_x - text_width / 2.0, // Default center + }; + + // Calculate vertical offset based on alignment + // For ASS/SSA subtitles, the anchor point represents: + // - Bottom alignment: where the bottom of the text block should be + // - Middle alignment: center of text box + // - Top alignment: top of text box + // We return the top-left corner position for rendering + let y = match mapped_alignment { + 1..=3 => { + // Bottom: anchor is where bottom of text should be + // Subtract text_height to get top of text box + // libass uses font metrics for exact positioning, not hardcoded factors + anchor_y - text_height + } + 4..=6 => anchor_y - text_height / 2.0, // Middle: anchor is center + 7..=9 => anchor_y, // Top: anchor is top edge + _ => anchor_y - text_height, // Default bottom + }; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!("apply_alignment_offset: alignment={}, anchor=({:.1}, {:.1}), size=({:.1}x{:.1}) -> pos=({:.1}, {:.1})", + mapped_alignment, anchor_x, anchor_y, text_width, text_height, x, y); + + (x, y) + } +} + +#[allow(dead_code)] // Utility for karaoke effects - used in future features +fn calculate_karaoke_progress(time_cs: u32, start_time_cs: u32, duration_cs: u32) -> f32 { + if time_cs < start_time_cs { + return 0.0; + } + let elapsed = time_cs - start_time_cs; + if elapsed >= duration_cs { + return 1.0; + } + elapsed as f32 / duration_cs as f32 +} + +impl Pipeline for SoftwarePipeline { + fn prepare_script( + &mut self, + script: &Script, + #[cfg(feature = "analysis-integration")] analysis: Option<&ScriptAnalysis>, + #[cfg(not(feature = "analysis-integration"))] _analysis: Option<()>, + ) -> Result<(), RenderError> { + // Load embedded and referenced fonts from the script + super::font_loader::load_script_fonts(script, &mut self.font_database); + + // Clear and rebuild styles map + self.styles_map.clear(); + self.default_style = None; + + // If we have analysis with resolved styles (which handle LayoutRes->PlayRes scaling), + // we should use those instead of raw styles + #[cfg(feature = "analysis-integration")] + let _use_resolved_styles = analysis.is_some(); + #[cfg(not(feature = "analysis-integration"))] + let _use_resolved_styles = false; + + // Extract script info and styles from the script + for section in script.sections() { + match section { + ass_core::parser::Section::ScriptInfo(info) => { + // Extract PlayResX and PlayResY from script info + if let Some((res_x, res_y)) = info.play_resolution() { + self.play_res_x = res_x as f32; + self.play_res_y = res_y as f32; + } + + // Extract LayoutResX and LayoutResY if present + if let Some((layout_x, layout_y)) = info.layout_resolution() { + self.layout_res_x = Some(layout_x as f32); + self.layout_res_y = Some(layout_y as f32); + + // If LayoutRes differs from PlayRes, we need to scale styles + // This is done later when processing styles + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + "LayoutRes detected: {}x{}, PlayRes: {}x{}", + layout_x, + layout_y, + self.play_res_x, + self.play_res_y + ); + } + + // Extract ScaledBorderAndShadow setting + // Default is "yes" per ASS spec, but can be "no" to disable scaling + if let Some(scaled_value) = info.get_field("ScaledBorderAndShadow") { + self.scaled_border_and_shadow = scaled_value.to_lowercase() != "no"; + } + } + ass_core::parser::Section::Styles(styles) => { + // Calculate LayoutRes->PlayRes scaling factors if LayoutRes is present + let layout_to_play_scale_x = if let Some(layout_x) = self.layout_res_x { + if layout_x != self.play_res_x { + self.play_res_x / layout_x + } else { + 1.0 + } + } else { + 1.0 + }; + + let layout_to_play_scale_y = if let Some(layout_y) = self.layout_res_y { + if layout_y != self.play_res_y { + self.play_res_y / layout_y + } else { + 1.0 + } + } else { + 1.0 + }; + + let needs_layout_scaling = + layout_to_play_scale_x != 1.0 || layout_to_play_scale_y != 1.0; + + for style in styles { + let style_name = style.name.to_string(); + let mut owned_style = OwnedStyle::from_style(style); + + // Apply LayoutRes->PlayRes scaling if needed + if needs_layout_scaling { + // Scale font size (using Y scale as per libass) + if let Ok(font_size) = owned_style.fontsize.parse::() { + owned_style.fontsize = + (font_size * layout_to_play_scale_y).to_string(); + } + + // Scale margins + if let Ok(margin_l) = owned_style.margin_l.parse::() { + owned_style.margin_l = + (margin_l * layout_to_play_scale_x).to_string(); + } + if let Ok(margin_r) = owned_style.margin_r.parse::() { + owned_style.margin_r = + (margin_r * layout_to_play_scale_x).to_string(); + } + if let Ok(margin_v) = owned_style.margin_v.parse::() { + owned_style.margin_v = + (margin_v * layout_to_play_scale_y).to_string(); + } + + // Scale outline and shadow if ScaledBorderAndShadow is enabled + if self.scaled_border_and_shadow { + if let Ok(outline) = owned_style.outline.parse::() { + owned_style.outline = + (outline * layout_to_play_scale_y).to_string(); + } + if let Ok(shadow) = owned_style.shadow.parse::() { + owned_style.shadow = + (shadow * layout_to_play_scale_y).to_string(); + } + } + + // Scale spacing + if let Ok(spacing) = owned_style.spacing.parse::() { + owned_style.spacing = + (spacing * layout_to_play_scale_x).to_string(); + } + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + debug_println!( + "Scaled style '{}' from LayoutRes to PlayRes", + style_name + ); + } + + if style_name == "Default" || style_name == "*Default" { + self.default_style = Some(owned_style.clone()); + } + + self.styles_map.insert(style_name, owned_style); + } + } + _ => {} + } + } + + // If no default style found, use the first one + if self.default_style.is_none() && !self.styles_map.is_empty() { + self.default_style = self.styles_map.values().next().cloned(); + } + + Ok(()) + } + + fn script(&self) -> Option<&Script<'_>> { + None // We don't store the script reference directly + } + + fn process_events( + &mut self, + events: &[&Event], + time_cs: u32, + context: &RenderContext, + ) -> Result, RenderError> { + // Clear collision resolver for this frame (but keep dimensions) + self.collision_resolver.clear(); + + // Pre-allocate with estimated capacity to reduce allocations + let mut all_layers = Vec::with_capacity(events.len() * 3); + + // Sort events first by layer, then by start time to ensure proper ordering + let mut sorted_events = events.to_vec(); + sorted_events.sort_by(|a, b| { + let layer_a = a.layer.parse::().unwrap_or(0); + let layer_b = b.layer.parse::().unwrap_or(0); + let start_a = a.start_time_cs().unwrap_or(0); + let start_b = b.start_time_cs().unwrap_or(0); + + // Sort by layer first, then by start time + layer_a.cmp(&layer_b).then(start_a.cmp(&start_b)) + }); + + // Process each event + for event in sorted_events { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if event.text.contains("Чысценькая") { + debug_println!( + "Processing target event: style={}, text_len={}", + event.style, + event.text.len() + ); + } + + let event_layers = self.process_event(event, time_cs, context)?; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if event.text.contains("Чысценькая") { + debug_println!(" Generated {} layers for target event", event_layers.len()); + } + + all_layers.extend(event_layers); + } + + Ok(all_layers) + } + + fn compute_dirty_regions( + &self, + events: &[&Event], + time_cs: u32, + prev_time_cs: u32, + ) -> Result, RenderError> { + let mut regions = Vec::new(); + + for event in events { + let was_active = event.start_time_cs().unwrap_or(0) <= prev_time_cs + && event.end_time_cs().unwrap_or(u32::MAX) > prev_time_cs; + let is_active = event.start_time_cs().unwrap_or(0) <= time_cs + && event.end_time_cs().unwrap_or(u32::MAX) > time_cs; + + if was_active != is_active { + // Event visibility changed, mark entire screen as dirty for now + regions.push(DirtyRegion::full_screen()); + break; + } + } + + Ok(regions) + } +} diff --git a/crates/ass-renderer/src/pipeline/software_pipeline_new.rs b/crates/ass-renderer/src/pipeline/software_pipeline_new.rs new file mode 100644 index 0000000..a2e9f6f --- /dev/null +++ b/crates/ass-renderer/src/pipeline/software_pipeline_new.rs @@ -0,0 +1,273 @@ +//! New process_event implementation with proper segment handling + +use crate::pipeline::{IntermediateLayer, TextData, TextEffect, VectorData}; +use crate::pipeline::text_segmenter::segment_text_with_tags; +use crate::pipeline::shaping::shape_text_with_style; +use crate::pipeline::drawing::process_drawing_commands; +use crate::pipeline::animation::{calculate_move_progress, calculate_fade_progress}; +use crate::renderer::RenderContext; +use crate::utils::RenderError; +use ass_core::parser::Event; +use smallvec::SmallVec; + +impl super::SoftwarePipeline { + /// Process a single event into layers with proper text segmentation + pub fn process_event_segmented( + &mut self, + event: &Event, + time_cs: u32, + context: &RenderContext, + ) -> Result, RenderError> { + // Get resolved style from analysis if available + let resolved_style = self.analysis.as_ref().and_then(|analysis| { + analysis.resolve_style(&event.style) + }); + + // Get text segments with their individual tags + let segments = segment_text_with_tags(&event.text, None)?; + + if segments.is_empty() { + return Ok(Vec::new()); + } + + // Check if this is a drawing command (should be in first segment) + if let Some(draw_level) = segments[0].tags.drawing_mode { + if draw_level > 0 { + // Process drawing command + let plain_text = segments[0].text.clone(); + let tags = &segments[0].tags; + + let path_opt = process_drawing_commands(&plain_text)?; + + if let Some(path) = path_opt { + let color = tags.colors.primary.unwrap_or_else(|| { + resolved_style + .map(|s| s.primary_color()) + .unwrap_or([255, 255, 255, 255]) + }); + + let (x, y) = if let Some((px, py)) = tags.position { + (px, py) + } else { + self.calculate_position(event, context, tags) + }; + + // Apply transform to path if needed + let transformed_path = if let Some((x1, y1, x2, y2, t1, t2)) = tags.movement { + let progress = calculate_move_progress(time_cs, t1, t2); + let curr_x = x1 + (x2 - x1) * progress; + let curr_y = y1 + (y2 - y1) * progress; + path.transform(tiny_skia::Transform::from_translate(curr_x, curr_y)) + } else { + path.transform(tiny_skia::Transform::from_translate(x, y)) + }; + + return Ok(vec![IntermediateLayer::Vector(VectorData { + path: transformed_path, + color, + stroke: None, + bounds: None, + })]); + } + return Ok(Vec::new()); + } + } + + // Process text segments + let mut all_layers = Vec::new(); + let mut current_x = 0.0; + let mut current_y = 0.0; + let mut line_height = 0.0; + + // Get base position from first segment (pos/move tags apply to whole event) + let base_tags = &segments[0].tags; + let (base_x, base_y) = if let Some((px, py)) = base_tags.position { + (px, py) + } else if let Some((x1, y1, x2, y2, t1, t2)) = base_tags.movement { + let progress = calculate_move_progress(time_cs, t1, t2); + let x = x1 + (x2 - x1) * progress; + let y = y1 + (y2 - y1) * progress; + (x, y) + } else { + self.calculate_position(event, context, base_tags) + }; + + current_x = base_x; + current_y = base_y; + + for segment in segments { + if segment.text.is_empty() { + continue; + } + + let tags = &segment.tags; + + // Handle line breaks in segment text + let lines: Vec<&str> = segment.text.split('\n').collect(); + + for (line_idx, line_text) in lines.iter().enumerate() { + if line_idx > 0 { + // Move to next line + current_y += line_height * 1.2; + current_x = base_x; + } + + if line_text.is_empty() { + continue; + } + + // Get font information for this segment + let font_family = tags.font.name.as_deref().unwrap_or_else(|| { + resolved_style + .map(|s| s.font_name()) + .unwrap_or("Arial") + }); + + let base_font_size = tags.font.size.unwrap_or_else(|| { + resolved_style + .map(|s| s.font_size()) + .unwrap_or(40.0) + }); + + let font_scale_x = tags.font.scale_x.unwrap_or(100.0) / 100.0; + let font_scale_y = tags.font.scale_y.unwrap_or(100.0) / 100.0; + let font_size = base_font_size * font_scale_y; + line_height = font_size; + + // Get formatting + let bold = tags.formatting.bold.unwrap_or_else(|| { + resolved_style.map(|s| s.is_bold()).unwrap_or(false) + }); + let italic = tags.formatting.italic.unwrap_or_else(|| { + resolved_style.map(|s| s.is_italic()).unwrap_or(false) + }); + + // Shape text + let shaped = shape_text_with_style( + line_text, + font_family, + font_size, + bold, + italic, + &self.font_database, + )?; + + // Get colors for this segment - THIS IS THE KEY FIX + let primary_color = tags.colors.primary.unwrap_or_else(|| { + resolved_style + .map(|s| s.primary_color()) + .unwrap_or([255, 255, 255, 255]) + }); + + // Apply alpha override if present + let mut color = primary_color; + if let Some(alpha) = tags.colors.alpha { + color[3] = alpha; + } + + // Apply fade if present + if let Some(fade) = &tags.fade { + // For \fad(t1,t2), t1 is fade-in duration, t2 is fade-out duration + // Calculate actual fade times relative to event + let event_start = event.start_time_cs().unwrap_or(0); + let event_end = event.end_time_cs().unwrap_or(u32::MAX); + + let fade_alpha = if fade.alpha_middle.is_some() { + // Complex fade with 7 parameters - times are absolute + let fade_progress = calculate_fade_progress(time_cs, fade.time_start, fade.time_end); + fade.alpha_start as f32 + (fade.alpha_end as f32 - fade.alpha_start as f32) * fade_progress + } else { + // Simple fade - times are durations + let fade_in_end = event_start + fade.time_start; + let fade_out_start = event_end.saturating_sub(fade.time_end); + + if time_cs < fade_in_end { + // During fade in + let progress = (time_cs.saturating_sub(event_start)) as f32 / fade.time_start.max(1) as f32; + 255.0 * progress.min(1.0) + } else if time_cs >= fade_out_start && fade_out_start < event_end { + // During fade out + let progress = (event_end.saturating_sub(time_cs)) as f32 / fade.time_end.max(1) as f32; + 255.0 * progress.min(1.0) + } else { + // Fully visible + 255.0 + } + }; + + color[3] = ((color[3] as f32 * (fade_alpha / 255.0)) as u8).min(255); + } + + // Create text layer for this segment + let mut layer = TextData { + text: line_text.to_string(), + font_family: font_family.to_string(), + font_size, + color, + x: current_x, + y: current_y, + effects: SmallVec::new(), + }; + + // Apply effects + if bold { + layer.effects.push(TextEffect::Bold); + } + + if italic { + layer.effects.push(TextEffect::Italic); + } + + if tags.formatting.underline.unwrap_or(false) { + layer.effects.push(TextEffect::Underline); + } + + if tags.formatting.strikeout.unwrap_or(false) { + layer.effects.push(TextEffect::Strikethrough); + } + + // Add outline + let outline_width = tags.formatting.border.unwrap_or_else(|| { + resolved_style.map(|s| s.outline()).unwrap_or(0.0) + }); + if outline_width > 0.0 { + let outline_color = tags.colors.outline.unwrap_or_else(|| { + resolved_style + .map(|s| s.outline_color()) + .unwrap_or([0, 0, 0, 255]) + }); + layer.effects.push(TextEffect::Outline { + color: outline_color, + width: outline_width, + }); + } + + // Add shadow + let shadow_depth = tags.formatting.shadow.unwrap_or_else(|| { + resolved_style.map(|s| s.shadow()).unwrap_or(0.0) + }); + if shadow_depth > 0.0 { + let shadow_color = tags.colors.shadow.unwrap_or_else(|| { + resolved_style + .map(|s| s.secondary_color()) + .unwrap_or([64, 64, 64, 128]) + }); + layer.effects.push(TextEffect::Shadow { + color: shadow_color, + x_offset: shadow_depth, + y_offset: shadow_depth, + }); + } + + // Update x position for next segment on same line + if let Some(advance) = shaped.total_advance() { + current_x += advance * font_scale_x; + } + + all_layers.push(IntermediateLayer::Text(layer)); + } + } + + Ok(all_layers) + } +} \ No newline at end of file diff --git a/crates/ass-renderer/src/pipeline/tag_processor.rs b/crates/ass-renderer/src/pipeline/tag_processor.rs new file mode 100644 index 0000000..0bd5284 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/tag_processor.rs @@ -0,0 +1,701 @@ +//! Tag processing for events using ass-core's tag parsing + +use crate::pipeline::transform::TransformAnimation; +use crate::utils::RenderError; +#[cfg(feature = "nostd")] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; +use ass_core::analysis::events::{OverrideTag, TextAnalysis}; +use ass_core::ExtensionRegistry; +#[cfg(not(feature = "nostd"))] +#[cfg(not(feature = "nostd"))] +use std::{ + eprintln, + string::{String, ToString}, + vec::Vec, +}; + +/// Processed tag values ready for rendering +#[derive(Debug, Clone, Default)] +pub struct ProcessedTags { + /// Position override (x, y) + pub position: Option<(f32, f32)>, + /// Move command (x1, y1, x2, y2, t1, t2) + pub movement: Option<(f32, f32, f32, f32, u32, u32)>, + /// Origin point for rotation (x, y) + pub origin: Option<(f32, f32)>, + /// Color overrides (primary, secondary, outline, shadow) + pub colors: ColorOverrides, + /// Font overrides + pub font: FontOverrides, + /// Text formatting overrides + pub formatting: FormattingOverrides, + /// Transform/animation - can have multiple transforms + pub transforms: Vec, + /// Drawing mode level + pub drawing_mode: Option, + /// Clipping region + pub clip: Option, + /// Fade effects + pub fade: Option, + /// Karaoke timing + pub karaoke: Option, + /// Reset style + pub reset: Option, + /// Baseline offset + pub baseline_offset: Option, + /// Perspective (\fax, \fay) + pub shear_x: Option, + pub shear_y: Option, + /// Line breaks and special characters + pub line_breaks: Vec, + /// Non-breaking spaces positions + pub nbsp_positions: Vec, +} + +#[derive(Debug, Clone, Default)] +/// Color override settings for rendering +pub struct ColorOverrides { + /// Primary color override + pub primary: Option<[u8; 4]>, + /// Secondary color override + pub secondary: Option<[u8; 4]>, + /// Outline color override + pub outline: Option<[u8; 4]>, + /// Shadow color override + pub shadow: Option<[u8; 4]>, + /// General alpha override + pub alpha: Option, + /// Primary color alpha override (\1a) + pub alpha1: Option, + /// Secondary color alpha override (\2a) + pub alpha2: Option, + /// Outline color alpha override (\3a) + pub alpha3: Option, + /// Shadow color alpha override (\4a) + pub alpha4: Option, +} + +#[derive(Debug, Clone, Default)] +/// Font override settings +pub struct FontOverrides { + pub name: Option, + pub size: Option, + pub scale_x: Option, + pub scale_y: Option, + pub spacing: Option, + pub angle: Option, + pub rotation_x: Option, // \frx + pub rotation_y: Option, // \fry + pub rotation_z: Option, // \frz + pub encoding: Option, // \fe +} + +#[derive(Debug, Clone, Default)] +/// Text formatting override settings +pub struct FormattingOverrides { + pub bold: Option, + pub italic: Option, + pub underline: Option, + pub strikeout: Option, + pub border: Option, + pub border_x: Option, // \xbord + pub border_y: Option, // \ybord + pub shadow: Option, + pub shadow_x: Option, // \xshad + pub shadow_y: Option, // \yshad + pub blur: Option, + pub blur_edges: Option, // \be (edge blur) + pub alignment: Option, + pub margin_l: Option, // \q + pub margin_r: Option, + pub margin_v: Option, + pub wrap_style: Option, // \q +} + +#[derive(Debug, Clone)] +pub struct TransformData { + pub animation: TransformAnimation, +} + +#[derive(Debug, Clone)] +pub struct ClipData { + pub x1: f32, + pub y1: f32, + pub x2: f32, + pub y2: f32, + pub inverse: bool, +} + +#[derive(Debug, Clone)] +pub struct FadeData { + pub alpha_start: u8, + pub alpha_end: u8, + pub time_start: u32, + pub time_end: u32, + /// For complex fade with 7 parameters + pub alpha_middle: Option, + pub time_fade_in: Option, + pub time_fade_out: Option, +} + +#[derive(Debug, Clone)] +/// Karaoke timing and style data +pub struct KaraokeData { + /// Duration of karaoke effect in centiseconds + pub duration: u32, + /// Style of karaoke effect + pub style: KaraokeStyle, + /// Karaoke syllable start time in centiseconds (for \kt) + pub start_time: Option, +} + +#[derive(Debug, Clone)] +/// Karaoke effect styles +pub enum KaraokeStyle { + /// Basic karaoke effect + Basic, + /// Fill-based karaoke effect + Fill, + /// Outline-based karaoke effect + Outline, + /// Sweep karaoke effect (\K capital K) + Sweep, +} + +#[derive(Debug, Clone)] +/// Line break types in ASS text +pub enum LineBreakType { + /// Hard line break (\N) at position in text + Hard(usize), + /// Soft line break (\n) at position in text + Soft(usize), +} + +/// Process tags from event text +pub fn process_event_tags( + text: &str, + _registry: Option<&ExtensionRegistry>, +) -> Result<(ProcessedTags, String), RenderError> { + // Analyze text to extract tags + #[cfg(feature = "plugins")] + let analysis = TextAnalysis::analyze_with_registry(text, _registry) + .map_err(|e| RenderError::InvalidScript(e.to_string()))?; + + #[cfg(not(feature = "plugins"))] + let analysis = + TextAnalysis::analyze(text).map_err(|e| RenderError::InvalidScript(e.to_string()))?; + + let mut processed = ProcessedTags::default(); + + // Process each tag + for tag in analysis.override_tags() { + process_single_tag(tag, &mut processed)?; + } + + // Process line breaks and special characters + // Note: ass-core's TextAnalysis already converts \N, \n, \h to actual characters + let plain_text = analysis.plain_text(); + let mut clean_text = String::new(); + + for ch in plain_text.chars() { + match ch { + '\n' => { + // Track line break positions + // We can't distinguish between \N (hard) and \n (soft) after conversion + // For now, treat all as hard breaks + processed + .line_breaks + .push(LineBreakType::Hard(clean_text.len())); + clean_text.push('\n'); + } + '\u{00A0}' => { + // Non-breaking space (from \h) + processed.nbsp_positions.push(clean_text.len()); + clean_text.push('\u{00A0}'); + } + _ => { + clean_text.push(ch); + } + } + } + + Ok((processed, clean_text)) +} + +fn process_single_tag(tag: &OverrideTag, processed: &mut ProcessedTags) -> Result<(), RenderError> { + match tag.name() { + // Position tags + "pos" => { + if let Some((x, y)) = parse_pos_args(tag.args()) { + processed.position = Some((x, y)); + } + } + "move" => { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("PARSING MOVE TAG: args = '{}'", tag.args()); + + if let Some(data) = parse_move_args(tag.args()) { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!( + " Parsed move: x1={}, y1={}, x2={}, y2={}, t1={}, t2={}", + data.0, data.1, data.2, data.3, data.4, data.5 + ); + processed.movement = Some(data); + } else { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!(" Failed to parse move args!"); + } + } + "org" => { + if let Some((x, y)) = parse_pos_args(tag.args()) { + processed.origin = Some((x, y)); + } + } + + // Color tags + "c" | "1c" => { + if let Some(color) = parse_color(tag.args()) { + processed.colors.primary = Some(color); + } + } + "2c" => { + if let Some(color) = parse_color(tag.args()) { + processed.colors.secondary = Some(color); + } + } + "3c" => { + if let Some(color) = parse_color(tag.args()) { + processed.colors.outline = Some(color); + } + } + "4c" => { + if let Some(color) = parse_color(tag.args()) { + processed.colors.shadow = Some(color); + } + } + "alpha" => { + if let Some(alpha) = parse_alpha(tag.args()) { + processed.colors.alpha = Some(alpha); + } + } + "1a" => { + if let Some(alpha) = parse_alpha(tag.args()) { + processed.colors.alpha1 = Some(alpha); + } + } + "2a" => { + if let Some(alpha) = parse_alpha(tag.args()) { + processed.colors.alpha2 = Some(alpha); + } + } + "3a" => { + if let Some(alpha) = parse_alpha(tag.args()) { + processed.colors.alpha3 = Some(alpha); + } + } + "4a" => { + if let Some(alpha) = parse_alpha(tag.args()) { + processed.colors.alpha4 = Some(alpha); + } + } + + // Font tags + "fn" => { + processed.font.name = Some(tag.args().trim().to_string()); + } + "fs" => { + if let Ok(size) = tag.args().parse::() { + processed.font.size = Some(size); + } + } + "fscx" => { + if let Ok(scale) = tag.args().parse::() { + processed.font.scale_x = Some(scale); // Keep as percentage + } + } + "fscy" => { + if let Ok(scale) = tag.args().parse::() { + processed.font.scale_y = Some(scale); // Keep as percentage + } + } + "fsp" => { + if let Ok(spacing) = tag.args().parse::() { + processed.font.spacing = Some(spacing); + } + } + "frz" | "fr" => { + if let Ok(angle) = tag.args().parse::() { + processed.font.angle = Some(angle); + processed.font.rotation_z = Some(angle); + } + } + "frx" => { + if let Ok(angle) = tag.args().parse::() { + processed.font.rotation_x = Some(angle); + } + } + "fry" => { + if let Ok(angle) = tag.args().parse::() { + processed.font.rotation_y = Some(angle); + } + } + "fax" => { + if let Ok(shear) = tag.args().parse::() { + processed.shear_x = Some(shear); + } + } + "fay" => { + if let Ok(shear) = tag.args().parse::() { + processed.shear_y = Some(shear); + } + } + "fe" => { + if let Ok(encoding) = tag.args().parse::() { + processed.font.encoding = Some(encoding); + } + } + + // Formatting tags + "b" => { + processed.formatting.bold = Some(tag.args() != "0"); + } + "i" => { + processed.formatting.italic = Some(tag.args() != "0"); + } + "u" => { + processed.formatting.underline = Some(tag.args() != "0"); + } + "s" => { + processed.formatting.strikeout = Some(tag.args() != "0"); + } + "bord" => { + if let Ok(width) = tag.args().parse::() { + processed.formatting.border = Some(width); + } + } + "xbord" => { + if let Ok(width) = tag.args().parse::() { + processed.formatting.border_x = Some(width); + } + } + "ybord" => { + if let Ok(width) = tag.args().parse::() { + processed.formatting.border_y = Some(width); + } + } + "shad" => { + if let Ok(depth) = tag.args().parse::() { + processed.formatting.shadow = Some(depth); + } + } + "xshad" => { + if let Ok(depth) = tag.args().parse::() { + processed.formatting.shadow_x = Some(depth); + } + } + "yshad" => { + if let Ok(depth) = tag.args().parse::() { + processed.formatting.shadow_y = Some(depth); + } + } + "be" => { + if let Ok(blur) = tag.args().parse::() { + processed.formatting.blur_edges = Some(blur); + } + } + "blur" => { + if let Ok(blur) = tag.args().parse::() { + processed.formatting.blur = Some(blur); + } + } + "a" | "an" => { + if let Ok(align) = tag.args().parse::() { + processed.formatting.alignment = Some(align); + } + } + "q" => { + if let Ok(wrap) = tag.args().parse::() { + processed.formatting.wrap_style = Some(wrap); + } + } + "r" => { + // Reset to style + processed.reset = Some(tag.args().trim().to_string()); + } + "pbo" => { + // Baseline offset + if let Ok(offset) = tag.args().parse::() { + processed.baseline_offset = Some(offset); + } + } + + // Drawing mode + "p" => { + if let Ok(level) = tag.args().parse::() { + processed.drawing_mode = Some(level); + } + } + + // Clipping + "clip" | "iclip" => { + if let Some(clip) = parse_clip_args(tag.args()) { + let mut clip = clip; + clip.inverse = tag.name() == "iclip"; + processed.clip = Some(clip); + } + } + + // Fade effects + "fad" | "fade" => { + if let Some(fade) = parse_fade_args(tag.args()) { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("FADE PARSE: Parsed fade tag with args '{}' -> FadeData {{ alpha_start: {}, alpha_end: {}, time_start: {}, time_end: {}, alpha_middle: {:?} }}", + tag.args(), fade.alpha_start, fade.alpha_end, fade.time_start, fade.time_end, fade.alpha_middle); + processed.fade = Some(fade); + } + } + + // Karaoke (durations are already in centiseconds in ASS format) + "k" => { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TAG PROCESSOR: Found \\k tag with args: '{}'", tag.args()); + + if let Ok(duration) = tag.args().parse::() { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TAG PROCESSOR: Parsed karaoke duration: {duration}"); + + processed.karaoke = Some(KaraokeData { + duration, // Already in centiseconds + style: KaraokeStyle::Basic, + start_time: None, + }); + } else { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!( + "TAG PROCESSOR: Failed to parse karaoke duration from '{}'", + tag.args() + ); + } + } + "kf" => { + if let Ok(duration) = tag.args().parse::() { + processed.karaoke = Some(KaraokeData { + duration, // Already in centiseconds + style: KaraokeStyle::Fill, + start_time: None, + }); + } + } + "ko" => { + if let Ok(duration) = tag.args().parse::() { + processed.karaoke = Some(KaraokeData { + duration, // Already in centiseconds + style: KaraokeStyle::Outline, + start_time: None, + }); + } + } + "K" => { + // Capital K is sweep karaoke + if let Ok(duration) = tag.args().parse::() { + processed.karaoke = Some(KaraokeData { + duration, // Already in centiseconds + style: KaraokeStyle::Sweep, + start_time: None, + }); + } + } + "kt" => { + // Karaoke syllable start time + if let Ok(start_time) = tag.args().parse::() { + if let Some(ref mut karaoke) = processed.karaoke { + karaoke.start_time = Some(start_time); + } else { + processed.karaoke = Some(KaraokeData { + duration: 0, + style: KaraokeStyle::Basic, + start_time: Some(start_time), + }); + } + } + } + + // Transform animation + "t" => { + // Parse transform animation + if let Some(transform) = TransformAnimation::parse(tag.args()) { + // Add transform to list (can have multiple) + processed.transforms.push(TransformData { + animation: transform, + }); + } + } + + _ => { + // Unknown tag or handled by plugin + } + } + + Ok(()) +} + +/// Parse position arguments from ASS \pos tag +pub fn parse_pos_args(args: &str) -> Option<(f32, f32)> { + let args = args.trim_start_matches('(').trim_end_matches(')'); + let parts: Vec<&str> = args.split(',').collect(); + if parts.len() == 2 { + if let (Ok(x), Ok(y)) = ( + parts[0].trim().parse::(), + parts[1].trim().parse::(), + ) { + return Some((x, y)); + } + } + None +} + +/// Parse movement arguments from ASS \move tag +pub fn parse_move_args(args: &str) -> Option<(f32, f32, f32, f32, u32, u32)> { + let args = args.trim_start_matches('(').trim_end_matches(')'); + let parts: Vec<&str> = args.split(',').collect(); + + if parts.len() >= 4 { + let x1 = parts[0].trim().parse::().ok()?; + let y1 = parts[1].trim().parse::().ok()?; + let x2 = parts[2].trim().parse::().ok()?; + let y2 = parts[3].trim().parse::().ok()?; + + // Times in \move are in milliseconds, need to convert to centiseconds + let t1_ms = if parts.len() > 4 { + parts[4].trim().parse::().unwrap_or(0) + } else { + 0 + }; + + let t2_ms = if parts.len() > 5 { + parts[5].trim().parse::().unwrap_or(0) + } else { + 0 + }; + + // Convert milliseconds to centiseconds + let t1 = t1_ms / 10; + let t2 = t2_ms / 10; + + return Some((x1, y1, x2, y2, t1, t2)); + } + + None +} + +/// Parse color arguments from ASS color tags +pub fn parse_color(args: &str) -> Option<[u8; 4]> { + // Use ass-core's color parsing + ass_core::utils::parse_bgr_color(args).ok() +} + +/// Parse alpha arguments from ASS alpha tags +pub fn parse_alpha(args: &str) -> Option { + let hex = args + .trim_start_matches("&H") + .trim_start_matches("&h") + .trim_end_matches('&'); + // ASS uses inverted alpha: 0 = opaque, 255 = transparent + // We need to invert it to match standard RGBA: 255 = opaque, 0 = transparent + u8::from_str_radix(hex, 16) + .ok() + .map(|ass_alpha| 255 - ass_alpha) +} + +fn parse_clip_args(args: &str) -> Option { + let args = args.trim_start_matches('(').trim_end_matches(')'); + let parts: Vec<&str> = args.split(',').collect(); + + if parts.len() == 4 { + let x1 = parts[0].trim().parse::().ok()?; + let y1 = parts[1].trim().parse::().ok()?; + let x2 = parts[2].trim().parse::().ok()?; + let y2 = parts[3].trim().parse::().ok()?; + + return Some(ClipData { + x1, + y1, + x2, + y2, + inverse: false, + }); + } + + None +} + +/// Parse fade arguments from ASS \fade tag +pub fn parse_fade_args(args: &str) -> Option { + let args = args.trim_start_matches('(').trim_end_matches(')'); + let parts: Vec<&str> = args.split(',').collect(); + + if parts.len() >= 2 { + // Simple fade: fade_in_time, fade_out_time (in milliseconds) + if parts.len() == 2 { + let fade_in_ms = parts[0].trim().parse::().ok()?; + let fade_out_ms = parts[1].trim().parse::().ok()?; + + // Convert milliseconds to centiseconds + let time_start = fade_in_ms / 10; + let time_end = fade_out_ms / 10; + + // For simple fade, we store durations not alpha values + // The actual alpha calculation happens during rendering + return Some(FadeData { + alpha_start: 0, // Not used for simple fade + alpha_end: 0, // Not used for simple fade + time_start, // Fade-in duration in centiseconds + time_end, // Fade-out duration in centiseconds + alpha_middle: None, + time_fade_in: None, + time_fade_out: None, + }); + } + + // Complex fade: alpha1, alpha2, alpha3, t1, t2, t3, t4 + // In ASS format, alpha values are INVERTED: 00=opaque, FF=transparent + // alpha1: alpha before fade in (00-FF in hex, where 00=opaque, FF=transparent) + // alpha2: alpha during main display + // alpha3: alpha after fade out + // t1-t2: fade in period (in milliseconds) + // t2-t3: fully visible period (in milliseconds) + // t3-t4: fade out period (in milliseconds) + if parts.len() >= 7 { + let alpha1 = parts[0].trim().parse::().ok()?; + let alpha2 = parts[1].trim().parse::().ok()?; + let alpha3 = parts[2].trim().parse::().ok()?; + let t1_ms = parts[3].trim().parse::().ok()?; + let t2_ms = parts[4].trim().parse::().ok()?; + let t3_ms = parts[5].trim().parse::().ok()?; + let t4_ms = parts[6].trim().parse::().ok()?; + + // Convert milliseconds to centiseconds + let t1 = t1_ms / 10; + let t2 = t2_ms / 10; + let t3 = t3_ms / 10; + let t4 = t4_ms / 10; + + // Store the ASS alpha values as-is (00=opaque, FF=transparent) + // They'll be inverted when applied + return Some(FadeData { + alpha_start: alpha1, + alpha_end: alpha3, + time_start: t1, + time_end: t4, + alpha_middle: Some(alpha2), + time_fade_in: Some(t2 - t1), + time_fade_out: Some(t4 - t3), + }); + } + } + + None +} diff --git a/crates/ass-renderer/src/pipeline/text_segmenter.rs b/crates/ass-renderer/src/pipeline/text_segmenter.rs new file mode 100644 index 0000000..1e0cba2 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/text_segmenter.rs @@ -0,0 +1,465 @@ +//! Text segmentation for handling inline formatting changes + +use crate::utils::RenderError; +use ass_core::analysis::events::TextAnalysis; +use ass_core::ExtensionRegistry; + +#[cfg(feature = "nostd")] +use alloc::{ + string::{String, ToString}, + vec, + vec::Vec, +}; +#[cfg(not(feature = "nostd"))] +use std::{ + string::{String, ToString}, + vec::Vec, +}; + +/// A text segment with its own formatting +#[derive(Debug, Clone)] +pub struct TextSegment { + /// The plain text content + pub text: String, + /// Starting position in original text + pub start: usize, + /// Ending position in original text + pub end: usize, + /// Active tags for this segment + pub tags: super::tag_processor::ProcessedTags, +} + +/// Process text with inline tag changes into segments +pub fn segment_text_with_tags( + text: &str, + _registry: Option<&ExtensionRegistry>, +) -> Result, RenderError> { + // Analyze text to get tags and their positions + #[cfg(feature = "plugins")] + let analysis = TextAnalysis::analyze_with_registry(text, _registry) + .map_err(|e| RenderError::InvalidScript(e.to_string()))?; + + #[cfg(not(feature = "plugins"))] + let analysis = + TextAnalysis::analyze(text).map_err(|e| RenderError::InvalidScript(e.to_string()))?; + + let tags = analysis.override_tags(); + if tags.is_empty() { + // No tags, return single segment with plain text + return Ok(vec![TextSegment { + text: analysis.plain_text().to_string(), + start: 0, + end: text.len(), + tags: super::tag_processor::ProcessedTags::default(), + }]); + } + + // Build segments based on tag positions + let mut segments = Vec::new(); + let mut current_tags = super::tag_processor::ProcessedTags::default(); + let mut last_pos = 0; + let mut current_text = String::new(); + + // Process text character by character, tracking tag blocks + let mut chars = text.chars(); + let mut pos = 0; + let mut in_tag = false; + let mut tag_start = 0; + let mut brace_depth = 0; + + while let Some(ch) = chars.next() { + if ch == '{' { + if !in_tag { + // Start of tag block + if !current_text.is_empty() { + // Save current segment + segments.push(TextSegment { + text: current_text.clone(), + start: last_pos, + end: pos, + tags: current_tags.clone(), + }); + current_text.clear(); + last_pos = pos; + } + in_tag = true; + tag_start = pos; + brace_depth = 1; + } else { + brace_depth += 1; + } + } else if ch == '}' && in_tag { + brace_depth -= 1; + if brace_depth == 0 { + // End of tag block - process tags in this block + let tag_content = &text[tag_start + 1..pos]; + + // Check if this block contains karaoke tags + let has_karaoke = tag_content.contains("\\k") || tag_content.contains("\\K"); + + if has_karaoke && !current_text.is_empty() { + // If we have karaoke and accumulated text, save it as a segment first + segments.push(TextSegment { + text: current_text.clone(), + start: last_pos, + end: tag_start, + tags: current_tags.clone(), + }); + current_text.clear(); + } + + process_tag_block(tag_content, &mut current_tags)?; + in_tag = false; + last_pos = pos + 1; + } + } else if !in_tag { + // Regular text + if ch == '\\' { + // Check for escape sequences + if let Some(next) = chars.next() { + pos += next.len_utf8(); + match next { + 'N' | 'n' => current_text.push('\n'), + 'h' => current_text.push('\u{00A0}'), + _ => { + current_text.push(ch); + current_text.push(next); + } + } + } else { + current_text.push(ch); + } + } else { + current_text.push(ch); + } + } + + pos += ch.len_utf8(); + } + + // Add final segment if there's remaining text + if !current_text.is_empty() || segments.is_empty() { + segments.push(TextSegment { + text: current_text, + start: last_pos, + end: text.len(), + tags: current_tags, + }); + } + + Ok(segments) +} + +/// Process tags within a tag block and update current tags +fn process_tag_block( + content: &str, + current: &mut super::tag_processor::ProcessedTags, +) -> Result<(), RenderError> { + // Parse tags more carefully to handle nested parentheses in \t tags + let parts = split_tags_carefully(content); + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("DEBUG: Processing tag block: '{content}', parts: {parts:?}"); + + for part in &parts { + if part.is_empty() { + continue; + } + + // Extract tag name and arguments + // Tag names can be alphabetic or start with a digit (like 1c, 3a, 4a) + // Special handling for font name tag (fn) which can have letters immediately after + let (name, args) = if part.starts_with("fn") && part.len() > 2 { + // Special case for \fn tag - everything after "fn" is the font name + ("fn", &part[2..]) + } else if part.starts_with(|c: char| c.is_ascii_digit()) { + // Handle tags like 1c, 2c, 3a, 4a + if let Some(idx) = part[1..] + .find(|c: char| !c.is_ascii_alphabetic()) + .map(|i| i + 1) + { + (&part[..idx], &part[idx..]) + } else { + (part.as_str(), "") + } + } else if let Some(idx) = part.find(|c: char| !c.is_ascii_alphabetic()) { + (&part[..idx], &part[idx..]) + } else { + (part.as_str(), "") + }; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("DEBUG: Tag name='{name}', args='{args}'"); + + // Process based on tag name + match name { + // Color tags + "c" | "1c" => { + if let Some(color) = super::tag_processor::parse_color(args) { + current.colors.primary = Some(color); + } + } + "2c" => { + if let Some(color) = super::tag_processor::parse_color(args) { + current.colors.secondary = Some(color); + } + } + "3c" => { + if let Some(color) = super::tag_processor::parse_color(args) { + current.colors.outline = Some(color); + } + } + "4c" => { + if let Some(color) = super::tag_processor::parse_color(args) { + current.colors.shadow = Some(color); + } + } + "alpha" => { + if let Some(alpha) = super::tag_processor::parse_alpha(args) { + current.colors.alpha = Some(alpha); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Set alpha={alpha} from args '{args}'"); + } else { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Failed to parse alpha from '{args}'"); + } + } + "1a" => { + if let Some(alpha) = super::tag_processor::parse_alpha(args) { + current.colors.alpha1 = Some(alpha); + } + } + "2a" => { + if let Some(alpha) = super::tag_processor::parse_alpha(args) { + current.colors.alpha2 = Some(alpha); + } + } + "3a" => { + if let Some(alpha) = super::tag_processor::parse_alpha(args) { + current.colors.alpha3 = Some(alpha); + } + } + "4a" => { + if let Some(alpha) = super::tag_processor::parse_alpha(args) { + current.colors.alpha4 = Some(alpha); + } + } + + // Font tags + "fn" => { + // Empty \fn resets to style default + if args.is_empty() { + current.font.name = None; + } else { + current.font.name = Some(args.to_string()); + } + } + "fs" => { + if let Ok(size) = args.parse::() { + current.font.size = Some(size); + } + } + "b" => { + current.formatting.bold = Some(args != "0"); + } + "i" => { + current.formatting.italic = Some(args != "0"); + } + "u" => { + current.formatting.underline = Some(args != "0"); + } + "s" => { + current.formatting.strikeout = Some(args != "0"); + } + + // Font scale and spacing tags + "fscx" => { + if let Ok(scale) = args.parse::() { + current.font.scale_x = Some(scale); + } + } + "fscy" => { + if let Ok(scale) = args.parse::() { + current.font.scale_y = Some(scale); + } + } + "fsp" => { + if let Ok(spacing) = args.parse::() { + current.font.spacing = Some(spacing); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("DEBUG text_segmenter: Parsed \\fsp{spacing} tag"); + } + } + + // Rotation tags + "frz" | "fr" => { + if let Ok(angle) = args.parse::() { + current.font.rotation_z = Some(angle); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Set rotation_z={angle} from \\frz"); + } + } + "frx" => { + if let Ok(angle) = args.parse::() { + current.font.rotation_x = Some(angle); + } + } + "fry" => { + if let Ok(angle) = args.parse::() { + current.font.rotation_y = Some(angle); + } + } + + // Alignment tags + "a" | "an" => { + if let Ok(align) = args.parse::() { + current.formatting.alignment = Some(align); + } + } + + // Position tags - these apply to the whole event, not segments + "pos" => { + if let Some((x, y)) = super::tag_processor::parse_pos_args(args) { + current.position = Some((x, y)); + } + } + "move" => { + if let Some(data) = super::tag_processor::parse_move_args(args) { + current.movement = Some(data); + } + } + + // Fade effects + "fad" | "fade" => { + // Import the parse_fade_args function + if let Some(fade) = super::tag_processor::parse_fade_args(args) { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Parsed fade tag with args '{}' -> FadeData {{ time_start: {}, time_end: {} }}", + args, fade.time_start, fade.time_end); + current.fade = Some(fade); + } + } + + // Drawing mode + "p" => { + if let Ok(level) = args.parse::() { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Parsed drawing mode \\p{level}"); + current.drawing_mode = Some(level); + } + } + + // Transform animation + "t" => { + // Transform tags need special handling for nested tags + use crate::pipeline::tag_processor::TransformData; + use crate::pipeline::transform::TransformAnimation; + if let Some(animation) = TransformAnimation::parse(args) { + current.transforms.push(TransformData { animation }); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Added transform animation from args '{args}'"); + } else { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Failed to parse transform from '{args}'"); + } + } + + // Karaoke tags + "k" => { + if let Ok(duration) = args.parse::() { + current.karaoke = Some(super::tag_processor::KaraokeData { + duration, + style: super::tag_processor::KaraokeStyle::Basic, + start_time: None, + }); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Set karaoke k={duration}"); + } + } + "kf" => { + if let Ok(duration) = args.parse::() { + current.karaoke = Some(super::tag_processor::KaraokeData { + duration, + style: super::tag_processor::KaraokeStyle::Fill, + start_time: None, + }); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Set karaoke kf={duration}"); + } + } + "ko" => { + if let Ok(duration) = args.parse::() { + current.karaoke = Some(super::tag_processor::KaraokeData { + duration, + style: super::tag_processor::KaraokeStyle::Outline, + start_time: None, + }); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Set karaoke ko={duration}"); + } + } + "K" => { + if let Ok(duration) = args.parse::() { + current.karaoke = Some(super::tag_processor::KaraokeData { + duration, + style: super::tag_processor::KaraokeStyle::Sweep, + start_time: None, + }); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TEXT_SEGMENTER: Set karaoke K={duration}"); + } + } + + _ => { + // Other tags can be added as needed + } + } + } + + Ok(()) +} + +// Re-export helper functions from tag_processor +pub use super::tag_processor::{parse_alpha, parse_color, parse_move_args, parse_pos_args}; + +/// Split tags carefully, handling nested parentheses in \t tags +fn split_tags_carefully(content: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut depth = 0; + let mut chars = content.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\\' && depth == 0 { + // Start of a new tag + if !current.is_empty() { + parts.push(current); + current = String::new(); + } + } else { + current.push(ch); + + // Track parentheses depth for \t tags + if ch == '(' { + depth += 1; + } else if ch == ')' { + depth -= 1; + + // If we closed all parentheses, this tag is complete + if depth == 0 && !current.is_empty() { + // Check if next char is a backslash (new tag) or continue + if chars.peek() == Some(&'\\') { + parts.push(current.clone()); + current = String::new(); + } + } + } + } + } + + if !current.is_empty() { + parts.push(current); + } + + parts +} diff --git a/crates/ass-renderer/src/pipeline/transform.rs b/crates/ass-renderer/src/pipeline/transform.rs new file mode 100644 index 0000000..86615b9 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/transform.rs @@ -0,0 +1,347 @@ +//! Transform animation support for \t tags + +#[cfg(feature = "nostd")] +use alloc::{string::String, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::{string::String, vec::Vec}; + +/// Transform animation data +#[derive(Debug, Clone)] +pub struct TransformAnimation { + /// Start time in milliseconds (relative to line start) + pub start_ms: u32, + /// End time in milliseconds (relative to line start) + pub end_ms: u32, + /// Acceleration factor (1.0 = linear, >1 = ease-in, <1 = ease-out) + pub accel: f32, + /// Tags to animate to + pub target_tags: Vec, +} + +/// Tags that can be animated +#[derive(Debug, Clone)] +pub enum AnimatableTag { + /// Font size + FontSize(f32), + /// Font scale X + FontScaleX(f32), + /// Font scale Y + FontScaleY(f32), + /// Font spacing + FontSpacing(f32), + /// Font rotation Z + FontRotationZ(f32), + /// Font rotation X (3D) + FontRotationX(f32), + /// Font rotation Y (3D) + FontRotationY(f32), + /// Primary color + PrimaryColor([u8; 4]), + /// Secondary color + SecondaryColor([u8; 4]), + /// Outline color + OutlineColor([u8; 4]), + /// Shadow color + ShadowColor([u8; 4]), + /// Alpha + Alpha(u8), + /// Border width + BorderWidth(f32), + /// Shadow depth + ShadowDepth(f32), + /// Blur + Blur(f32), +} + +impl TransformAnimation { + /// Parse transform tag arguments + pub fn parse(args: &str) -> Option { + // Transform can have formats: + // \t(tags) - animate over full duration + // \t(accel,tags) - with acceleration + // \t(t1,t2,tags) - with time range + // \t(t1,t2,accel,tags) - with time range and acceleration + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TransformAnimation::parse called with args: '{args}'"); + + let args = args.trim(); + if !args.starts_with('(') || !args.ends_with(')') { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TransformAnimation::parse failed - missing parentheses"); + return None; + } + + let inner = &args[1..args.len() - 1]; + + // Find where tags start (after numeric parameters) + let mut tag_start = 0; + let mut _depth = 0; + let in_tag = false; + + for (i, ch) in inner.chars().enumerate() { + if ch == '\\' && !in_tag { + tag_start = i; + break; + } + if ch == '(' { + _depth += 1; + } else if ch == ')' { + _depth -= 1; + } + } + + if tag_start == 0 { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TransformAnimation::parse failed - no tags found"); + return None; + } + + let params = &inner[..tag_start]; + let tags = &inner[tag_start..]; + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TransformAnimation::parse - params: '{params}', tags: '{tags}'"); + + // Parse parameters - need to be careful since last param might be tags not accel + let parts: Vec<&str> = params + .trim_end_matches(',') + .split(',') + .map(|s| s.trim()) + .collect(); + + let (start_ms, end_ms, accel) = + if parts.is_empty() || (parts.len() == 1 && parts[0].is_empty()) { + // No parameters, just tags + (0, 0, 1.0) + } else if parts.len() == 1 { + // Could be acceleration or time1 + if let Ok(val) = parts[0].parse::() { + if val < 10.0 { + // Likely acceleration (values typically 0.1 to 5) + (0, 0, val) + } else { + // Likely time value + (0, val as u32, 1.0) + } + } else { + (0, 0, 1.0) + } + } else if parts.len() == 2 { + // Two params: t1,t2 + let t1 = parts[0].parse::().unwrap_or(0); + let t2 = parts[1].parse::().unwrap_or(0); + (t1, t2, 1.0) + } else { + // Three or more params: t1,t2,accel + let t1 = parts[0].parse::().unwrap_or(0); + let t2 = parts[1].parse::().unwrap_or(0); + // Only parse third param as accel if it's a valid float + let accel = parts[2].parse::().unwrap_or(1.0); + (t1, t2, accel) + }; + + // Parse target tags + let target_tags = parse_animatable_tags(tags); + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!( + "TransformAnimation::parse - parsed {} target tags", + target_tags.len() + ); + + if target_tags.is_empty() { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("TransformAnimation::parse failed - no target tags found"); + return None; + } + + Some(TransformAnimation { + start_ms, + end_ms, + accel, + target_tags, + }) + } + + /// Calculate interpolation progress for current time + pub fn calculate_progress(&self, current_ms: u32) -> f32 { + if current_ms <= self.start_ms { + return 0.0; + } + if self.end_ms > 0 && current_ms >= self.end_ms { + return 1.0; + } + + let duration = if self.end_ms > 0 { + self.end_ms - self.start_ms + } else { + // Use full line duration if end time not specified + current_ms - self.start_ms + }; + + if duration == 0 { + return 1.0; + } + + let linear_progress = (current_ms - self.start_ms) as f32 / duration as f32; + + // Apply acceleration curve + apply_acceleration_curve(linear_progress, self.accel) + } +} + +/// Apply acceleration curve to linear progress +/// +/// Implements various easing functions based on acceleration value: +/// - accel = 1.0: Linear interpolation +/// - accel < 1.0: Ease-out (fast start, slow end) +/// - accel > 1.0: Ease-in (slow start, fast end) +/// - Special handling for common values like 0.5, 2.0, 3.0 +fn apply_acceleration_curve(t: f32, accel: f32) -> f32 { + // Clamp t to [0, 1] + let t = t.clamp(0.0, 1.0); + + if (accel - 1.0).abs() < 0.001 { + // Linear interpolation + t + } else if (accel - 0.5).abs() < 0.001 { + // Strong ease-out (quadratic) + 1.0 - (1.0 - t).powi(2) + } else if (accel - 2.0).abs() < 0.001 { + // Quadratic ease-in + t * t + } else if (accel - 3.0).abs() < 0.001 { + // Cubic ease-in + t * t * t + } else if accel < 0.0 { + // Negative acceleration: bounce or elastic effect + // Simplified bounce for negative values + let bounce = (t * core::f32::consts::PI).sin(); + t + bounce * 0.1 * accel.abs() + } else { + // General exponential easing + // Uses pow for custom acceleration curves + t.powf(accel) + } +} + +/// Parse tags that can be animated +fn parse_animatable_tags(tag_string: &str) -> Vec { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!("parse_animatable_tags: input='{tag_string}'"); + + let mut tags = Vec::new(); + let mut pos = 0; + let chars: Vec = tag_string.chars().collect(); + + while pos < chars.len() { + if chars[pos] == '\\' { + pos += 1; + let tag_start = pos; + + // Parse tag name - stop at first non-letter character after initial letter(s) + // unless it's a tag that starts with a digit (like 1c, 2c, 3a, 4a) + let first_char = if tag_start < chars.len() { + chars[tag_start] + } else { + ' ' + }; + if first_char.is_ascii_digit() { + // Tags like 1c, 2c, 3a, 4a + pos += 1; // Skip the digit + while pos < chars.len() && chars[pos].is_ascii_alphabetic() { + pos += 1; + } + } else { + // Normal tags - only letters + while pos < chars.len() && chars[pos].is_ascii_alphabetic() { + pos += 1; + } + } + + if pos > tag_start { + let tag_name = chars[tag_start..pos].iter().collect::(); + + // Parse tag arguments + let arg_start = pos; + while pos < chars.len() && chars[pos] != '\\' { + pos += 1; + } + + let args = chars[arg_start..pos].iter().collect::(); + + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!(" Found tag: name='{tag_name}', args='{args}'"); + + // Create animatable tag + if let Some(tag) = create_animatable_tag(&tag_name, &args) { + tags.push(tag); + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!(" Created animatable tag for '{tag_name}'"); + } else { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!(" Failed to create animatable tag for '{tag_name}'"); + } + } + } else { + pos += 1; + } + } + + tags +} + +fn create_animatable_tag(name: &str, args: &str) -> Option { + match name { + "fs" => args.parse::().ok().map(AnimatableTag::FontSize), + "fscx" => args.parse::().ok().map(AnimatableTag::FontScaleX), + "fscy" => args.parse::().ok().map(AnimatableTag::FontScaleY), + "fsp" => args.parse::().ok().map(AnimatableTag::FontSpacing), + "frz" | "fr" => args.parse::().ok().map(AnimatableTag::FontRotationZ), + "frx" => args.parse::().ok().map(AnimatableTag::FontRotationX), + "fry" => args.parse::().ok().map(AnimatableTag::FontRotationY), + "c" | "1c" => parse_color_arg(args).map(AnimatableTag::PrimaryColor), + "2c" => parse_color_arg(args).map(AnimatableTag::SecondaryColor), + "3c" => parse_color_arg(args).map(AnimatableTag::OutlineColor), + "4c" => parse_color_arg(args).map(AnimatableTag::ShadowColor), + "alpha" => parse_alpha_arg(args).map(AnimatableTag::Alpha), + "bord" => args.parse::().ok().map(AnimatableTag::BorderWidth), + "shad" => args.parse::().ok().map(AnimatableTag::ShadowDepth), + "blur" | "be" => args.parse::().ok().map(AnimatableTag::Blur), + _ => None, + } +} + +fn parse_color_arg(args: &str) -> Option<[u8; 4]> { + ass_core::utils::parse_bgr_color(args).ok() +} + +fn parse_alpha_arg(args: &str) -> Option { + let hex = args.trim_start_matches("&H").trim_start_matches("&h"); + // ASS uses inverted alpha: 0 = opaque, 255 = transparent + // We need to invert it to match standard RGBA: 255 = opaque, 0 = transparent + u8::from_str_radix(hex, 16) + .ok() + .map(|ass_alpha| 255 - ass_alpha) +} + +/// Interpolate between two values based on progress +pub fn interpolate_f32(from: f32, to: f32, progress: f32) -> f32 { + from + (to - from) * progress +} + +/// Interpolate between two colors +pub fn interpolate_color(from: [u8; 4], to: [u8; 4], progress: f32) -> [u8; 4] { + [ + (from[0] as f32 + (to[0] as f32 - from[0] as f32) * progress) as u8, + (from[1] as f32 + (to[1] as f32 - from[1] as f32) * progress) as u8, + (from[2] as f32 + (to[2] as f32 - from[2] as f32) * progress) as u8, + (from[3] as f32 + (to[3] as f32 - from[3] as f32) * progress) as u8, + ] +} + +/// Interpolate alpha value +pub fn interpolate_alpha(from: u8, to: u8, progress: f32) -> u8 { + (from as f32 + (to as f32 - from as f32) * progress) as u8 +} diff --git a/crates/ass-renderer/src/pipeline/validation.rs b/crates/ass-renderer/src/pipeline/validation.rs new file mode 100644 index 0000000..ec97445 --- /dev/null +++ b/crates/ass-renderer/src/pipeline/validation.rs @@ -0,0 +1,185 @@ +//! Tag parameter validation module + +#[cfg(feature = "nostd")] +use alloc::{format, string::ToString}; +#[cfg(not(feature = "nostd"))] +use std::string::ToString; + +use crate::utils::RenderError; + +/// Validate alignment value (1-9 for numpad positions) +pub fn validate_alignment(value: u8) -> Result { + if (1..=9).contains(&value) { + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid alignment value: {value}. Must be 1-9" + ))) + } +} + +/// Validate wrap style (0-3) +pub fn validate_wrap_style(value: u8) -> Result { + if value <= 3 { + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid wrap style: {value}. Must be 0-3" + ))) + } +} + +/// Validate drawing mode level (0-4) +pub fn validate_drawing_mode(value: u8) -> Result { + if value <= 4 { + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid drawing mode: {value}. Must be 0-4" + ))) + } +} + +/// Validate font size (must be positive) +pub fn validate_font_size(value: f32) -> Result { + if value > 0.0 && value < 1000.0 { + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid font size: {value}. Must be positive and reasonable" + ))) + } +} + +/// Validate scale percentage (0-1000%) +pub fn validate_scale(value: f32) -> Result { + if (0.0..=1000.0).contains(&value) { + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid scale: {value}%. Must be 0-1000" + ))) + } +} + +/// Validate rotation angle (degrees) +pub fn validate_rotation(value: f32) -> Result { + // Normalize to 0-360 range + let normalized = value % 360.0; + Ok(if normalized < 0.0 { + normalized + 360.0 + } else { + normalized + }) +} + +/// Validate border width (non-negative) +pub fn validate_border_width(value: f32) -> Result { + if (0.0..100.0).contains(&value) { + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid border width: {value}. Must be non-negative" + ))) + } +} + +/// Validate blur amount (non-negative) +pub fn validate_blur(value: f32) -> Result { + if (0.0..100.0).contains(&value) { + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid blur: {value}. Must be non-negative" + ))) + } +} + +/// Validate color array (BGRA format) +pub fn validate_color(color: [u8; 4]) -> Result<[u8; 4], RenderError> { + // Colors are already in valid u8 range + Ok(color) +} + +/// Validate alpha value (0-255) +pub fn validate_alpha(value: u8) -> Result { + // u8 is already in valid range + Ok(value) +} + +/// Validate time in milliseconds +pub fn validate_time_ms(value: u32) -> Result { + // Allow any u32 value for time + Ok(value) +} + +/// Validate karaoke duration (centiseconds) +pub fn validate_karaoke_duration(value: u32) -> Result { + if value < 100000 { + // Max ~16 minutes per syllable + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid karaoke duration: {value} centiseconds" + ))) + } +} + +/// Validate position coordinates +pub fn validate_position(x: f32, y: f32) -> Result<(f32, f32), RenderError> { + // Allow negative values for off-screen positioning + if x.is_finite() && y.is_finite() { + Ok((x, y)) + } else { + Err(RenderError::InvalidScript( + "Invalid position: coordinates must be finite".to_string(), + )) + } +} + +/// Validate clipping rectangle +pub fn validate_clip_rect( + x1: f32, + y1: f32, + x2: f32, + y2: f32, +) -> Result<(f32, f32, f32, f32), RenderError> { + if x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite() { + // Ensure x2 > x1 and y2 > y1 + let (x1, x2) = if x1 <= x2 { (x1, x2) } else { (x2, x1) }; + let (y1, y2) = if y1 <= y2 { (y1, y2) } else { (y2, y1) }; + Ok((x1, y1, x2, y2)) + } else { + Err(RenderError::InvalidScript( + "Invalid clip rectangle: coordinates must be finite".to_string(), + )) + } +} + +/// Validate font encoding (0-255, though only specific values are meaningful) +pub fn validate_font_encoding(value: u8) -> Result { + // Common encodings: 0 (ANSI), 1 (Default), 128 (Shift-JIS), 134 (GB2312), etc. + Ok(value) +} + +/// Validate shear/perspective factor +pub fn validate_shear(value: f32) -> Result { + if (-2.0..=2.0).contains(&value) { + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid shear factor: {value}. Should be between -2 and 2" + ))) + } +} + +/// Validate acceleration factor for transforms +pub fn validate_acceleration(value: f32) -> Result { + if value > 0.0 && value < 100.0 { + Ok(value) + } else { + Err(RenderError::InvalidScript(format!( + "Invalid acceleration: {value}. Must be positive" + ))) + } +} diff --git a/crates/ass-renderer/src/plugin/effects/mod.rs b/crates/ass-renderer/src/plugin/effects/mod.rs new file mode 100644 index 0000000..200ec1d --- /dev/null +++ b/crates/ass-renderer/src/plugin/effects/mod.rs @@ -0,0 +1,45 @@ +//! Custom effect plugins + +use crate::plugin::{EffectParams, EffectPlugin}; +use crate::utils::RenderError; + +/// Example custom glow effect +pub struct GlowEffect { + radius: f32, +} + +impl GlowEffect { + /// Create a new glow effect + pub fn new(radius: f32) -> Self { + Self { radius } + } +} + +impl EffectPlugin for GlowEffect { + fn name(&self) -> &str { + "Glow" + } + + fn version(&self) -> &str { + "1.0.0" + } + + fn apply_cpu( + &self, + pixels: &mut [u8], + width: u32, + height: u32, + params: &EffectParams, + ) -> Result<(), RenderError> { + // Simple glow effect implementation + let strength = params.strength; + let _ = (pixels, width, height, strength, self.radius); + // TODO: Implement actual glow effect + Ok(()) + } + + fn shader_code(&self) -> Option<&str> { + // TODO: Add WGSL shader code for GPU acceleration + None + } +} diff --git a/crates/ass-renderer/src/plugin/mod.rs b/crates/ass-renderer/src/plugin/mod.rs new file mode 100644 index 0000000..dae403d --- /dev/null +++ b/crates/ass-renderer/src/plugin/mod.rs @@ -0,0 +1,169 @@ +//! Plugin system for custom effects and backends + +use crate::utils::RenderError; +use ahash::AHashMap; + +#[cfg(feature = "nostd")] +use alloc::{ + string::{String, ToString}, + sync::Arc, + vec::Vec, +}; +#[cfg(not(feature = "nostd"))] +use std::{ + string::{String, ToString}, + sync::Arc, + vec::Vec, +}; + +pub mod effects; + +/// Effect plugin trait for custom effects +pub trait EffectPlugin: Send + Sync { + /// Get plugin name + fn name(&self) -> &str; + + /// Get plugin version + fn version(&self) -> &str; + + /// Apply effect to pixel data + fn apply_cpu( + &self, + pixels: &mut [u8], + width: u32, + height: u32, + params: &EffectParams, + ) -> Result<(), RenderError>; + + /// Get shader code for GPU backends + fn shader_code(&self) -> Option<&str> { + None + } + + /// Check if effect supports GPU acceleration + fn supports_gpu(&self) -> bool { + self.shader_code().is_some() + } +} + +/// Effect parameters +#[derive(Debug, Clone)] +pub struct EffectParams { + /// Effect strength (0.0-1.0) + pub strength: f32, + /// Custom parameters + pub custom: AHashMap, +} + +/// Effect parameter value +#[derive(Debug, Clone)] +pub enum EffectValue { + /// Float value + Float(f32), + /// Integer value + Integer(i32), + /// Color value (RGBA) + Color([u8; 4]), + /// Boolean value + Boolean(bool), + /// String value + String(String), +} + +impl EffectParams { + /// Create new effect parameters + pub fn new(strength: f32) -> Self { + Self { + strength, + custom: AHashMap::new(), + } + } + + /// Set a custom parameter + pub fn set(&mut self, key: impl Into, value: EffectValue) { + self.custom.insert(key.into(), value); + } + + /// Get a custom parameter + pub fn get(&self, key: &str) -> Option<&EffectValue> { + self.custom.get(key) + } + + /// Get parameter as float + pub fn get_float(&self, key: &str) -> Option { + self.custom.get(key).and_then(|v| { + if let EffectValue::Float(f) = v { + Some(*f) + } else { + None + } + }) + } + + /// Get parameter as integer + pub fn get_int(&self, key: &str) -> Option { + self.custom.get(key).and_then(|v| { + if let EffectValue::Integer(i) = v { + Some(*i) + } else { + None + } + }) + } + + /// Get parameter as color + pub fn get_color(&self, key: &str) -> Option<[u8; 4]> { + self.custom.get(key).and_then(|v| { + if let EffectValue::Color(c) = v { + Some(*c) + } else { + None + } + }) + } +} + +/// Plugin registry for managing plugins +pub struct PluginRegistry { + effects: AHashMap>, +} + +impl PluginRegistry { + /// Create a new plugin registry + pub fn new() -> Self { + Self { + effects: AHashMap::new(), + } + } + + /// Register an effect plugin + pub fn register_effect(&mut self, plugin: Arc) { + self.effects.insert(plugin.name().to_string(), plugin); + } + + /// Get an effect plugin by name + pub fn get_effect(&self, name: &str) -> Option> { + self.effects.get(name).cloned() + } + + /// List all registered effects + pub fn list_effects(&self) -> Vec { + self.effects.keys().cloned().collect() + } + + /// Remove an effect plugin + pub fn unregister_effect(&mut self, name: &str) -> Option> { + self.effects.remove(name) + } + + /// Clear all plugins + pub fn clear(&mut self) { + self.effects.clear(); + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ass-renderer/src/renderer/context.rs b/crates/ass-renderer/src/renderer/context.rs new file mode 100644 index 0000000..14d1b33 --- /dev/null +++ b/crates/ass-renderer/src/renderer/context.rs @@ -0,0 +1,155 @@ +//! Rendering context with fonts, resolution, and backend configuration + +use fontdb::Database as FontDatabase; + +#[cfg(feature = "nostd")] +use alloc::sync::Arc; +#[cfg(not(feature = "nostd"))] +use std::sync::Arc; + +/// Rendering context containing fonts, resolution, and configuration +#[derive(Clone)] +pub struct RenderContext { + width: u32, + height: u32, + font_database: Arc, + playback_res_x: u32, + playback_res_y: u32, + storage_res_x: u32, + storage_res_y: u32, + frame_rate: f32, + par: f32, +} + +impl RenderContext { + /// Create a new render context with the given dimensions + pub fn new(width: u32, height: u32) -> Self { + let mut font_database = FontDatabase::new(); + font_database.load_system_fonts(); + + Self { + width, + height, + font_database: Arc::new(font_database), + playback_res_x: width, + playback_res_y: height, + storage_res_x: width, + storage_res_y: height, + frame_rate: 24.0, + par: 1.0, + } + } + + /// Create context with custom font database + pub fn with_font_database(width: u32, height: u32, font_database: FontDatabase) -> Self { + Self { + width, + height, + font_database: Arc::new(font_database), + playback_res_x: width, + playback_res_y: height, + storage_res_x: width, + storage_res_y: height, + frame_rate: 24.0, + par: 1.0, + } + } + + /// Set playback resolution (from script info) + pub fn set_playback_resolution(&mut self, x: u32, y: u32) { + self.playback_res_x = x; + self.playback_res_y = y; + } + + /// Set storage resolution (original script resolution) + pub fn set_storage_resolution(&mut self, x: u32, y: u32) { + self.storage_res_x = x; + self.storage_res_y = y; + } + + /// Set frame rate + pub fn set_frame_rate(&mut self, fps: f32) { + self.frame_rate = fps; + } + + /// Set pixel aspect ratio + pub fn set_pixel_aspect_ratio(&mut self, par: f32) { + self.par = par; + } + + /// Get render width + pub fn width(&self) -> u32 { + self.width + } + + /// Get render height + pub fn height(&self) -> u32 { + self.height + } + + /// Get font database + pub fn font_database(&self) -> &FontDatabase { + &self.font_database + } + + /// Get mutable font database + pub fn font_database_mut(&mut self) -> &mut FontDatabase { + Arc::get_mut(&mut self.font_database).expect("Font database has multiple references") + } + + /// Get playback resolution X + pub fn playback_res_x(&self) -> u32 { + self.playback_res_x + } + + /// Get playback resolution Y + pub fn playback_res_y(&self) -> u32 { + self.playback_res_y + } + + /// Get storage resolution X + pub fn storage_res_x(&self) -> u32 { + self.storage_res_x + } + + /// Get storage resolution Y + pub fn storage_res_y(&self) -> u32 { + self.storage_res_y + } + + /// Get frame rate + pub fn frame_rate(&self) -> f32 { + self.frame_rate + } + + /// Get pixel aspect ratio + pub fn pixel_aspect_ratio(&self) -> f32 { + self.par + } + + /// Calculate X scale factor from storage to playback resolution + pub fn scale_x(&self) -> f32 { + self.playback_res_x as f32 / self.storage_res_x.max(1) as f32 + } + + /// Calculate Y scale factor from storage to playback resolution + pub fn scale_y(&self) -> f32 { + self.playback_res_y as f32 / self.storage_res_y.max(1) as f32 + } + + /// Calculate X scale factor from playback to render resolution + pub fn render_scale_x(&self) -> f32 { + self.width as f32 / self.playback_res_x.max(1) as f32 + } + + /// Calculate Y scale factor from playback to render resolution + pub fn render_scale_y(&self) -> f32 { + self.height as f32 / self.playback_res_y.max(1) as f32 + } +} + +impl Default for RenderContext { + fn default() -> Self { + Self::new(1920, 1080) + } +} diff --git a/crates/ass-renderer/src/renderer/event_selector.rs b/crates/ass-renderer/src/renderer/event_selector.rs new file mode 100644 index 0000000..7e5950c --- /dev/null +++ b/crates/ass-renderer/src/renderer/event_selector.rs @@ -0,0 +1,266 @@ +//! Event selection and dirty region tracking for incremental rendering + +use crate::utils::RenderError; +use ass_core::parser::{ast::EventType, Event, Script, Section}; + +#[cfg(feature = "nostd")] +use alloc::{collections::BTreeSet, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::collections::HashSet; + +/// Tracks active events and dirty regions for optimized rendering +#[derive(Debug, Clone)] +pub struct EventSelector { + /// Cache of previously active event indices + #[cfg(not(feature = "nostd"))] + previous_active: HashSet, + #[cfg(feature = "nostd")] + previous_active: BTreeSet, + + /// Last rendered timestamp + last_timestamp: Option, + + /// Dirty regions that need re-rendering + dirty_regions: Vec, + + /// Whether to render comment events (for signs and complex effects) + render_comments: bool, +} + +/// A region that needs re-rendering +#[derive(Debug, Clone)] +pub struct DirtyRegion { + /// X coordinate of the region + pub x: u32, + /// Y coordinate of the region + pub y: u32, + /// Width of the region + pub width: u32, + /// Height of the region + pub height: u32, +} + +/// Result of event selection with dirty tracking +#[derive(Debug)] +pub struct ActiveEvents<'a> { + /// Currently active events + pub events: Vec<&'a Event<'a>>, + /// Indices of newly activated events + pub newly_active: Vec, + /// Indices of newly deactivated events + pub newly_inactive: Vec, + /// Whether the frame needs re-rendering + pub is_dirty: bool, +} + +impl EventSelector { + /// Create a new event selector + pub fn new() -> Self { + Self { + #[cfg(not(feature = "nostd"))] + previous_active: HashSet::new(), + #[cfg(feature = "nostd")] + previous_active: BTreeSet::new(), + last_timestamp: None, + dirty_regions: Vec::new(), + render_comments: true, // Enable by default for compatibility with sign rendering + } + } + + /// Set whether to render comment events + pub fn set_render_comments(&mut self, render: bool) { + self.render_comments = render; + } + + /// Select active events and track changes for incremental rendering + pub fn select_active<'a>( + &mut self, + script: &'a Script<'a>, + time_cs: u32, + ) -> Result, RenderError> { + let mut active_events = Vec::new(); + #[cfg(not(feature = "nostd"))] + let mut current_active = HashSet::new(); + #[cfg(feature = "nostd")] + let mut current_active = BTreeSet::new(); + + // Find all active events + if let Some(events_section) = script.sections().iter().find_map(|section| { + if let Section::Events(events) = section { + Some(events) + } else { + None + } + }) { + #[cfg(all(debug_assertions, not(feature = "nostd")))] + eprintln!( + "EventSelector: Checking {} events at time {}", + events_section.len(), + time_cs + ); + for (idx, event) in events_section.iter().enumerate() { + // Include both Dialogue and optionally Comment events + let should_include = match event.event_type { + EventType::Dialogue => true, + EventType::Comment => self.render_comments, + _ => false, + }; + + if should_include { + let start = event.start_time_cs().unwrap_or(0); + let end = event.end_time_cs().unwrap_or(0); + + // Debug output for fade events + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if event.text.contains("\\fad") { + eprintln!("EventSelector: Fade event - start={}cs, end={}cs, time_cs={}cs, active={}", + start, end, time_cs, start <= time_cs && end >= time_cs); + } + + // Debug output for the specific dialogue we're looking for + #[cfg(all(debug_assertions, not(feature = "nostd")))] + if event.text.contains("Чысценькая") { + eprintln!( + "Found target dialogue: start={}, end={}, current_time={}, active={}", + start, + end, + time_cs, + start <= time_cs && end >= time_cs + ); + } + + // Events are active from start time (inclusive) to end time (inclusive) + // This matches ASS/SSA specification and libass behavior + if start <= time_cs && end >= time_cs { + active_events.push(event); + current_active.insert(idx); + } + } + } + } + + // Track changes for incremental rendering + let newly_active: Vec = current_active + .iter() + .filter(|idx| !self.previous_active.contains(idx)) + .cloned() + .collect(); + + let newly_inactive: Vec = self + .previous_active + .iter() + .filter(|idx| !current_active.contains(idx)) + .cloned() + .collect(); + + // Check if re-render is needed + let is_dirty = !newly_active.is_empty() + || !newly_inactive.is_empty() + || self.has_animated_events(&active_events, time_cs) + || self + .last_timestamp + .is_none_or(|last| (time_cs as i32 - last as i32).abs() > 100); + + // Update state + self.previous_active = current_active; + self.last_timestamp = Some(time_cs); + + Ok(ActiveEvents { + events: active_events, + newly_active, + newly_inactive, + is_dirty, + }) + } + + /// Check if any events have active animations + fn has_animated_events(&self, events: &[&Event], time_cs: u32) -> bool { + for event in events { + let text = event.text; + // Check for animation tags + if text.contains(r"\t(") + || text.contains(r"\move(") + || text.contains(r"\fade(") + || text.contains(r"\fad(") + { + return true; + } + // Check for karaoke + if text.contains(r"\k") || text.contains(r"\K") { + if let Ok(start) = event.start_time_cs() { + if time_cs > start { + return true; + } + } + } + } + false + } + + /// Add a dirty region for partial re-rendering + pub fn add_dirty_region(&mut self, x: u32, y: u32, width: u32, height: u32) { + // Merge overlapping regions for efficiency + for region in &mut self.dirty_regions { + if Self::regions_overlap(region, x, y, width, height) { + // Expand existing region + let min_x = region.x.min(x); + let min_y = region.y.min(y); + let max_x = (region.x + region.width).max(x + width); + let max_y = (region.y + region.height).max(y + height); + region.x = min_x; + region.y = min_y; + region.width = max_x - min_x; + region.height = max_y - min_y; + return; + } + } + + self.dirty_regions.push(DirtyRegion { + x, + y, + width, + height, + }); + } + + /// Check if two regions overlap + fn regions_overlap(region: &DirtyRegion, x: u32, y: u32, width: u32, height: u32) -> bool { + !(x >= region.x + region.width + || x + width <= region.x + || y >= region.y + region.height + || y + height <= region.y) + } + + /// Get current dirty regions + pub fn dirty_regions(&self) -> &[DirtyRegion] { + &self.dirty_regions + } + + /// Clear dirty regions after rendering + pub fn clear_dirty_regions(&mut self) { + self.dirty_regions.clear(); + } + + /// Reset selector state + pub fn reset(&mut self) { + self.previous_active.clear(); + self.last_timestamp = None; + self.dirty_regions.clear(); + } +} + +impl Default for EventSelector { + fn default() -> Self { + Self::new() + } +} + +/// Legacy function for backward compatibility +#[allow(dead_code)] // Kept for backward compatibility +pub fn select_active_events<'a>(script: &'a Script<'a>, time_cs: u32) -> Vec<&'a Event<'a>> { + let mut selector = EventSelector::new(); + selector + .select_active(script, time_cs) + .map(|active| active.events) + .unwrap_or_default() +} diff --git a/crates/ass-renderer/src/renderer/frame.rs b/crates/ass-renderer/src/renderer/frame.rs new file mode 100644 index 0000000..c2b3dd9 --- /dev/null +++ b/crates/ass-renderer/src/renderer/frame.rs @@ -0,0 +1,166 @@ +//! Rendered frame representation + +#[cfg(feature = "nostd")] +use alloc::{vec, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::vec::Vec; + +/// Rendered frame containing pixel data +#[derive(Clone)] +pub struct Frame { + buffer: Vec, + width: u32, + height: u32, + timestamp: u32, + format: PixelFormat, +} + +/// Pixel format for frame data +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PixelFormat { + /// RGBA with 8 bits per channel + Rgba8, + /// BGRA with 8 bits per channel + Bgra8, + /// RGB with 8 bits per channel + Rgb8, +} + +impl Frame { + /// Create a new frame with the given buffer + pub fn new(buffer: Vec, width: u32, height: u32, timestamp: u32) -> Self { + Self { + buffer, + width, + height, + timestamp, + format: PixelFormat::Rgba8, + } + } + + /// Create a frame from RGBA data + pub fn from_rgba(buffer: Vec, width: u32, height: u32) -> Self { + Self { + buffer, + width, + height, + timestamp: 0, + format: PixelFormat::Rgba8, + } + } + + /// Create a new frame with specific pixel format + pub fn with_format( + buffer: Vec, + width: u32, + height: u32, + timestamp: u32, + format: PixelFormat, + ) -> Self { + Self { + buffer, + width, + height, + timestamp, + format, + } + } + + /// Create an empty frame (transparent) + pub fn empty(width: u32, height: u32, timestamp: u32) -> Self { + let size = (width * height * 4) as usize; + Self { + buffer: vec![0; size], + width, + height, + timestamp, + format: PixelFormat::Rgba8, + } + } + + /// Get frame buffer data + pub fn data(&self) -> &[u8] { + &self.buffer + } + + /// Get frame pixels (alias for data()) + pub fn pixels(&self) -> &[u8] { + &self.buffer + } + + /// Get mutable frame buffer data + pub fn data_mut(&mut self) -> &mut [u8] { + &mut self.buffer + } + + /// Take ownership of the buffer + pub fn into_buffer(self) -> Vec { + self.buffer + } + + /// Get frame width + pub fn width(&self) -> u32 { + self.width + } + + /// Get frame height + pub fn height(&self) -> u32 { + self.height + } + + /// Get frame timestamp in centiseconds + pub fn timestamp(&self) -> u32 { + self.timestamp + } + + /// Get pixel format + pub fn format(&self) -> PixelFormat { + self.format + } + + /// Get bytes per pixel + pub fn bytes_per_pixel(&self) -> usize { + match self.format { + PixelFormat::Rgba8 | PixelFormat::Bgra8 => 4, + PixelFormat::Rgb8 => 3, + } + } + + /// Get stride (bytes per row) + pub fn stride(&self) -> usize { + self.width as usize * self.bytes_per_pixel() + } + + /// Convert to RGBA format if not already + pub fn to_rgba(mut self) -> Self { + match self.format { + PixelFormat::Rgba8 => self, + PixelFormat::Bgra8 => { + for chunk in self.buffer.chunks_exact_mut(4) { + chunk.swap(0, 2); + } + self.format = PixelFormat::Rgba8; + self + } + PixelFormat::Rgb8 => { + let mut rgba = Vec::with_capacity((self.width * self.height * 4) as usize); + for chunk in self.buffer.chunks_exact(3) { + rgba.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]); + } + self.buffer = rgba; + self.format = PixelFormat::Rgba8; + self + } + } + } + + /// Check if frame is empty (all transparent) + pub fn is_empty(&self) -> bool { + match self.format { + PixelFormat::Rgba8 | PixelFormat::Bgra8 => { + self.buffer.chunks_exact(4).all(|pixel| pixel[3] == 0) + } + PixelFormat::Rgb8 => false, + } + } +} diff --git a/crates/ass-renderer/src/renderer/mod.rs b/crates/ass-renderer/src/renderer/mod.rs new file mode 100644 index 0000000..d7456d8 --- /dev/null +++ b/crates/ass-renderer/src/renderer/mod.rs @@ -0,0 +1,264 @@ +//! Core renderer implementation + +use crate::backends::RenderBackend; +use crate::pipeline::Pipeline; +use crate::utils::RenderError; +#[cfg(feature = "analysis-integration")] +use ass_core::analysis::ScriptAnalysis; +use ass_core::parser::Script; + +#[cfg(feature = "nostd")] +use alloc::{boxed::Box, sync::Arc}; +#[cfg(not(feature = "nostd"))] +use std::{boxed::Box, sync::Arc, time::Duration}; + +mod context; +mod event_selector; +mod frame; +mod probing; + +pub use context::RenderContext; +pub use event_selector::{ActiveEvents, DirtyRegion, EventSelector}; +pub use frame::Frame; +pub use probing::BackendProber; + +/// Main renderer that coordinates rendering pipeline +pub struct Renderer { + context: RenderContext, + backend: Arc, + pipeline: Box, + event_selector: event_selector::EventSelector, +} + +impl Renderer { + /// Create a new renderer with the given backend type and context + pub fn new( + backend_type: crate::backends::BackendType, + context: RenderContext, + ) -> Result { + let backend = + crate::backends::create_backend(backend_type, context.width(), context.height())?; + let pipeline = backend.create_pipeline()?; + + Ok(Self { + context, + backend, + pipeline, + event_selector: event_selector::EventSelector::new(), + }) + } + + /// Create a new renderer with a specific backend instance + pub fn with_backend( + context: RenderContext, + backend: Arc, + ) -> Result { + let pipeline = backend.create_pipeline()?; + + Ok(Self { + context, + backend, + pipeline, + event_selector: event_selector::EventSelector::new(), + }) + } + + /// Create renderer with automatic backend detection + #[cfg(feature = "backend-probing")] + pub fn with_auto_backend(context: RenderContext) -> Result { + let prober = BackendProber::new(); + let backend = prober.probe_best_backend(&context)?; + Self::with_backend(context, backend) + } + + /// Render a frame for the given script at the specified time + pub fn render_frame(&mut self, script: &Script, time_cs: u32) -> Result { + #[cfg(feature = "analysis-integration")] + let analysis = ScriptAnalysis::analyze(script).ok(); + #[cfg(not(feature = "analysis-integration"))] + let analysis: Option<()> = None; + + // Extract script resolution and update context + for section in script.sections() { + if let ass_core::parser::Section::ScriptInfo(info) = section { + if let Some((play_x, play_y)) = info.play_resolution() { + self.context.set_playback_resolution(play_x, play_y); + } + if let Some((layout_x, layout_y)) = info.layout_resolution() { + self.context.set_storage_resolution(layout_x, layout_y); + } + break; // Only need first ScriptInfo section + } + } + + let active = self.event_selector.select_active(script, time_cs)?; + let events = active.events; + + if events.is_empty() { + return Ok(Frame::empty( + self.context.width(), + self.context.height(), + time_cs, + )); + } + + #[cfg(feature = "analysis-integration")] + self.pipeline.prepare_script(script, analysis.as_ref())?; + #[cfg(not(feature = "analysis-integration"))] + self.pipeline.prepare_script(script, None)?; + let layers = self + .pipeline + .process_events(&events, time_cs, &self.context)?; + + // Debug: Check what layers we have + // eprintln!("RENDERER: Got {} layers", layers.len()); + for layer in layers.iter() { + match layer { + crate::pipeline::IntermediateLayer::Vector(_) => { + // eprintln!("RENDERER: Layer {} is VectorData", i); + } + crate::pipeline::IntermediateLayer::Text(_) => { + // eprintln!("RENDERER: Layer {} is TextData", i); + } + crate::pipeline::IntermediateLayer::Raster(_) => { + // eprintln!("RENDERER: Layer {} is RasterData", i); + } + } + } + + let frame_data = self.backend.composite_layers(&layers, &self.context)?; + + Ok(Frame::new( + frame_data, + self.context.width(), + self.context.height(), + time_cs, + )) + } + + /// Render frame incrementally (dirty regions only) + pub fn render_frame_incremental( + &mut self, + script: &Script, + time_cs: u32, + previous_frame: &Frame, + ) -> Result { + let active = self.event_selector.select_active(script, time_cs)?; + let events = active.events; + let dirty_regions = + self.pipeline + .compute_dirty_regions(&events, time_cs, previous_frame.timestamp())?; + + if dirty_regions.is_empty() { + return Ok(previous_frame.clone()); + } + + #[cfg(feature = "analysis-integration")] + let analysis = ScriptAnalysis::analyze(script).ok(); + #[cfg(not(feature = "analysis-integration"))] + let analysis: Option<()> = None; + + #[cfg(feature = "analysis-integration")] + self.pipeline.prepare_script(script, analysis.as_ref())?; + #[cfg(not(feature = "analysis-integration"))] + self.pipeline.prepare_script(script, None)?; + let layers = self + .pipeline + .process_events(&events, time_cs, &self.context)?; + let frame_data = self.backend.composite_layers_incremental( + &layers, + &dirty_regions, + previous_frame.data(), + &self.context, + )?; + + Ok(Frame::new( + frame_data, + self.context.width(), + self.context.height(), + time_cs, + )) + } + + /// Get current backend type + pub fn backend_type(&self) -> crate::backends::BackendType { + self.backend.backend_type() + } + + /// Get backend metrics if available + #[cfg(feature = "backend-metrics")] + pub fn backend_metrics(&self) -> Option { + self.backend.metrics() + } + + /// Update render context + pub fn set_context(&mut self, context: RenderContext) { + self.context = context; + } + + /// Get render context + pub fn context(&self) -> &RenderContext { + &self.context + } + + /// Get mutable render context + pub fn context_mut(&mut self) -> &mut RenderContext { + &mut self.context + } + + /// Set collision resolver for subtitle positioning + pub fn set_collision_resolver( + &mut self, + _resolver: Box, + ) { + // TODO: Implement collision resolver integration + } + + /// Get performance metrics if available + pub fn metrics(&self) -> Option { + // TODO: Implement metrics collection + None + } + + /// Get cache statistics + pub fn cache_stats(&self) -> CacheStatistics { + CacheStatistics { + glyph_hits: 0, + font_entries: 0, + } + } +} + +/// Performance metrics for rendering +#[derive(Debug, Clone)] +pub struct PerformanceMetrics { + #[cfg(not(feature = "nostd"))] + /// Time spent parsing the script + pub parse_time: Duration, + #[cfg(not(feature = "nostd"))] + /// Time spent shaping text + pub shape_time: Duration, + #[cfg(not(feature = "nostd"))] + /// Time spent rendering + pub render_time: Duration, + #[cfg(not(feature = "nostd"))] + /// Total time for the operation + pub total_time: Duration, + #[cfg(feature = "nostd")] + pub parse_time: u64, // milliseconds + #[cfg(feature = "nostd")] + pub shape_time: u64, // milliseconds + #[cfg(feature = "nostd")] + pub render_time: u64, // milliseconds + #[cfg(feature = "nostd")] + pub total_time: u64, // milliseconds +} + +/// Cache statistics +#[derive(Debug, Clone)] +pub struct CacheStatistics { + /// Number of glyph cache hits + pub glyph_hits: usize, + /// Number of font database entries + pub font_entries: usize, +} diff --git a/crates/ass-renderer/src/renderer/probing.rs b/crates/ass-renderer/src/renderer/probing.rs new file mode 100644 index 0000000..f08f331 --- /dev/null +++ b/crates/ass-renderer/src/renderer/probing.rs @@ -0,0 +1,173 @@ +//! Backend auto-detection and probing + +use crate::backends::{BackendType, RenderBackend}; +use crate::renderer::RenderContext; +use crate::utils::RenderError; + +#[cfg(feature = "nostd")] +use alloc::{sync::Arc, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::{sync::Arc, vec::Vec}; + +/// Backend prober for automatic backend selection +pub struct BackendProber { + preferred_order: Vec, +} + +impl BackendProber { + /// Create a new backend prober with default preferences + #[allow(clippy::vec_init_then_push)] // Conditional compilation makes vec! macro difficult + #[allow(unused_mut)] // mut is needed for conditional features + pub fn new() -> Self { + let mut preferred_order = Vec::new(); + + #[cfg(all(feature = "web-backend", target_arch = "wasm32"))] + { + preferred_order.push(BackendType::WebGPU); + preferred_order.push(BackendType::WebGL); + } + + #[cfg(all(feature = "vulkan", not(target_arch = "wasm32")))] + preferred_order.push(BackendType::Vulkan); + + #[cfg(all(feature = "metal", target_os = "macos"))] + preferred_order.push(BackendType::Metal); + + #[cfg(feature = "software-backend")] + preferred_order.push(BackendType::Software); + + Self { preferred_order } + } + + /// Create prober with custom backend preference order + pub fn with_preference(preferred_order: Vec) -> Self { + Self { preferred_order } + } + + /// Probe for the best available backend + pub fn probe_best_backend( + &self, + context: &RenderContext, + ) -> Result, RenderError> { + for backend_type in &self.preferred_order { + match self.try_create_backend(*backend_type, context) { + Ok(backend) => { + #[cfg(not(feature = "nostd"))] + eprintln!("Selected {} backend for rendering", backend_type.as_str()); + return Ok(backend); + } + Err(_e) => { + #[cfg(not(feature = "nostd"))] + eprintln!( + "Failed to initialize {} backend: {}", + backend_type.as_str(), + _e + ); + } + } + } + + Err(RenderError::NoBackendAvailable) + } + + /// Try to create a specific backend + fn try_create_backend( + &self, + backend_type: BackendType, + context: &RenderContext, + ) -> Result, RenderError> { + match backend_type { + #[cfg(feature = "software-backend")] + BackendType::Software => { + use crate::backends::software::SoftwareBackend; + Ok(Arc::new(SoftwareBackend::new(context)?)) + } + + #[cfg(all(feature = "vulkan", not(feature = "nostd")))] + BackendType::Vulkan => { + use crate::backends::hardware::VulkanBackend; + Ok(Arc::new(VulkanBackend::new()?)) + } + + #[cfg(feature = "metal")] + BackendType::Metal => { + use crate::backends::hardware::metal::MetalBackend; + Ok(Arc::new(MetalBackend::new(context)?)) + } + + #[cfg(feature = "web-backend")] + BackendType::WebGPU => { + use crate::backends::web::WebGpuBackend; + Ok(Arc::new(WebGpuBackend::from_dimensions( + context.width(), + context.height(), + ))) + } + + #[cfg(feature = "web-backend")] + BackendType::WebGL => { + // WebGL backend not implemented yet + Err(RenderError::UnsupportedBackend( + "WebGL backend not implemented", + )) + } + + _ => Err(RenderError::UnsupportedBackend(backend_type.as_str())), + } + } + + /// Check if a backend is available + pub fn is_backend_available(&self, backend_type: BackendType) -> bool { + match backend_type { + #[cfg(feature = "software-backend")] + BackendType::Software => true, + + #[cfg(feature = "vulkan")] + BackendType::Vulkan => { + // Could add runtime Vulkan availability check here + true + } + + #[cfg(feature = "metal")] + BackendType::Metal => { + #[cfg(target_os = "macos")] + { + true + } + #[cfg(not(target_os = "macos"))] + { + false + } + } + + #[cfg(feature = "web-backend")] + BackendType::WebGPU | BackendType::WebGL => { + #[cfg(target_arch = "wasm32")] + { + true + } + #[cfg(not(target_arch = "wasm32"))] + { + false + } + } + + _ => false, + } + } + + /// Get list of available backends + pub fn available_backends(&self) -> Vec { + self.preferred_order + .iter() + .filter(|&&backend| self.is_backend_available(backend)) + .copied() + .collect() + } +} + +impl Default for BackendProber { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ass-renderer/src/utils/caches.rs b/crates/ass-renderer/src/utils/caches.rs new file mode 100644 index 0000000..82900e7 --- /dev/null +++ b/crates/ass-renderer/src/utils/caches.rs @@ -0,0 +1,184 @@ +//! Caching utilities for glyphs and textures + +use ahash::AHashMap; + +#[cfg(feature = "nostd")] +use alloc::{string::String, vec, vec::Vec}; +#[cfg(not(feature = "nostd"))] +use std::{string::String, vec::Vec}; + +/// Glyph cache for rendered glyphs +pub struct GlyphCache { + cache: AHashMap, + max_entries: usize, +} + +/// Key for glyph cache lookup +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct GlyphKey { + /// Font family + pub font_family: String, + /// Glyph ID + pub glyph_id: u16, + /// Font size + pub size: u32, + /// Style flags (bold, italic, etc.) + pub style_flags: u32, +} + +/// Cached glyph data +pub struct CachedGlyph { + /// Glyph bitmap data + pub bitmap: Vec, + /// Bitmap width + pub width: u32, + /// Bitmap height + pub height: u32, + /// Horizontal advance + pub advance: f32, + /// Bearing X + pub bearing_x: f32, + /// Bearing Y + pub bearing_y: f32, +} + +impl GlyphCache { + /// Create a new glyph cache + pub fn new(max_entries: usize) -> Self { + Self { + cache: AHashMap::new(), + max_entries, + } + } + + /// Get a cached glyph + pub fn get(&self, key: &GlyphKey) -> Option<&CachedGlyph> { + self.cache.get(key) + } + + /// Insert a glyph into the cache + pub fn insert(&mut self, key: GlyphKey, glyph: CachedGlyph) { + if self.cache.len() >= self.max_entries { + // Simple eviction: remove first entry + if let Some(first_key) = self.cache.keys().next().cloned() { + self.cache.remove(&first_key); + } + } + self.cache.insert(key, glyph); + } + + /// Clear the cache + pub fn clear(&mut self) { + self.cache.clear(); + } + + /// Get cache size + pub fn len(&self) -> usize { + self.cache.len() + } + + /// Check if cache is empty + pub fn is_empty(&self) -> bool { + self.cache.is_empty() + } +} + +/// Texture atlas for GPU backends +pub struct TextureAtlas { + width: u32, + height: u32, + data: Vec, + allocations: Vec, + next_x: u32, + next_y: u32, + row_height: u32, +} + +/// Region in texture atlas +#[derive(Debug, Clone)] +pub struct AtlasRegion { + /// Region ID + pub id: u32, + /// X coordinate in atlas + pub x: u32, + /// Y coordinate in atlas + pub y: u32, + /// Region width + pub width: u32, + /// Region height + pub height: u32, +} + +impl TextureAtlas { + /// Create a new texture atlas + pub fn new(width: u32, height: u32) -> Self { + Self { + width, + height, + data: vec![0; (width * height * 4) as usize], + allocations: Vec::new(), + next_x: 0, + next_y: 0, + row_height: 0, + } + } + + /// Allocate a region in the atlas + pub fn allocate(&mut self, width: u32, height: u32) -> Option { + // Simple row-based packing + if self.next_x + width > self.width { + // Move to next row + self.next_x = 0; + self.next_y += self.row_height; + self.row_height = 0; + } + + if self.next_y + height > self.height { + // Atlas is full + return None; + } + + let region = AtlasRegion { + id: self.allocations.len() as u32, + x: self.next_x, + y: self.next_y, + width, + height, + }; + + self.next_x += width; + self.row_height = self.row_height.max(height); + self.allocations.push(region.clone()); + + Some(region) + } + + /// Write data to a region + pub fn write_region(&mut self, region: &AtlasRegion, data: &[u8]) { + let stride = self.width * 4; + for y in 0..region.height { + let src_offset = (y * region.width * 4) as usize; + let dst_offset = ((region.y + y) * stride + region.x * 4) as usize; + let src_end = src_offset + (region.width * 4) as usize; + let dst_end = dst_offset + (region.width * 4) as usize; + + if src_end <= data.len() && dst_end <= self.data.len() { + self.data[dst_offset..dst_end].copy_from_slice(&data[src_offset..src_end]); + } + } + } + + /// Get atlas data + pub fn data(&self) -> &[u8] { + &self.data + } + + /// Clear the atlas + pub fn clear(&mut self) { + self.data.fill(0); + self.allocations.clear(); + self.next_x = 0; + self.next_y = 0; + self.row_height = 0; + } +} diff --git a/crates/ass-renderer/src/utils/errors.rs b/crates/ass-renderer/src/utils/errors.rs new file mode 100644 index 0000000..1d573f1 --- /dev/null +++ b/crates/ass-renderer/src/utils/errors.rs @@ -0,0 +1,193 @@ +//! Error types for rendering + +#[cfg(feature = "nostd")] +use alloc::string::String; +#[cfg(feature = "nostd")] +use core::fmt; +#[cfg(not(feature = "nostd"))] +use std::string::String; + +#[cfg(not(feature = "nostd"))] +use thiserror::Error; + +/// Rendering error types +#[cfg_attr(not(feature = "nostd"), derive(Error))] +#[derive(Debug)] +pub enum RenderError { + /// Invalid dimensions provided + #[cfg_attr( + not(feature = "nostd"), + error("Invalid dimensions: dimensions must be positive and non-zero") + )] + InvalidDimensions, + + /// Invalid buffer size + #[cfg_attr( + not(feature = "nostd"), + error("Invalid buffer size: expected {expected} bytes, got {actual}") + )] + InvalidBufferSize { + /// Expected size + expected: usize, + /// Actual size + actual: usize, + }, + + /// Invalid pixmap creation + #[cfg_attr(not(feature = "nostd"), error("Failed to create pixmap"))] + InvalidPixmap, + + /// No backend available + #[cfg_attr(not(feature = "nostd"), error("No rendering backend available"))] + NoBackendAvailable, + + /// Unsupported backend + #[cfg_attr(not(feature = "nostd"), error("Unsupported backend: {0}"))] + UnsupportedBackend(&'static str), + + /// Backend initialization failed + #[cfg_attr(not(feature = "nostd"), error("Backend initialization failed: {0}"))] + BackendInitFailed(String), + + /// Generic backend error + #[cfg_attr(not(feature = "nostd"), error("Backend error: {0}"))] + BackendError(String), + + /// Pipeline error + #[cfg_attr(not(feature = "nostd"), error("Pipeline error: {0}"))] + PipelineError(String), + + /// Shaping error + #[cfg_attr(not(feature = "nostd"), error("Text shaping failed: {0}"))] + ShapingError(String), + + /// Drawing error + #[cfg_attr(not(feature = "nostd"), error("Drawing failed: {0}"))] + DrawingError(String), + + /// Invalid draw command + #[cfg_attr(not(feature = "nostd"), error("Invalid draw command: {0}"))] + InvalidDrawCommand(String), + + /// Effect error + #[cfg_attr(not(feature = "nostd"), error("Effect application failed: {0}"))] + EffectError(String), + + /// Compositing error + #[cfg_attr(not(feature = "nostd"), error("Compositing failed: {0}"))] + CompositingError(String), + + /// Font error + #[cfg_attr(not(feature = "nostd"), error("Font error: {0}"))] + FontError(String), + + /// GPU error + #[cfg_attr(not(feature = "nostd"), error("GPU error: {0}"))] + GpuError(String), + + /// WASM error + #[cfg(target_arch = "wasm32")] + #[cfg_attr(not(feature = "nostd"), error("WASM error: {0}"))] + WasmError(String), + + /// Resource limit exceeded + #[cfg_attr(not(feature = "nostd"), error("Resource limit exceeded: {0}"))] + ResourceLimitExceeded(String), + + /// Invalid script + #[cfg_attr(not(feature = "nostd"), error("Invalid script: {0}"))] + InvalidScript(String), + + /// Parse error + #[cfg_attr(not(feature = "nostd"), error("Parse error: {0}"))] + ParseError(String), + + /// Invalid input + #[cfg_attr(not(feature = "nostd"), error("Invalid input: {0}"))] + InvalidInput(String), + + /// Core error from ass-core + #[cfg_attr(not(feature = "nostd"), error("Core error: {0}"))] + CoreError(#[cfg_attr(not(feature = "nostd"), from)] ass_core::utils::CoreError), + + /// Initialization error + #[cfg_attr(not(feature = "nostd"), error("Initialization error: {0}"))] + InitializationError(String), + + /// IO error + #[cfg_attr(not(feature = "nostd"), error("IO error: {0}"))] + IOError(String), + + /// Invalid state + #[cfg_attr(not(feature = "nostd"), error("Invalid state: {0}"))] + InvalidState(String), + + /// Unsupported operation + #[cfg_attr(not(feature = "nostd"), error("Unsupported operation: {0}"))] + UnsupportedOperation(String), +} + +impl RenderError { + /// Check if error is recoverable + pub fn is_recoverable(&self) -> bool { + matches!( + self, + Self::ShapingError(_) + | Self::DrawingError(_) + | Self::EffectError(_) + | Self::FontError(_) + ) + } + + /// Check if error indicates missing resources + pub fn is_resource_error(&self) -> bool { + matches!(self, Self::FontError(_) | Self::ResourceLimitExceeded(_)) + } +} + +// Manual Display implementation for nostd +#[cfg(feature = "nostd")] +impl fmt::Display for RenderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidDimensions => write!( + f, + "Invalid dimensions: dimensions must be positive and non-zero" + ), + Self::InvalidBufferSize { expected, actual } => { + write!( + f, + "Invalid buffer size: expected {expected} bytes, got {actual}" + ) + } + Self::InvalidPixmap => write!(f, "Failed to create pixmap"), + Self::NoBackendAvailable => write!(f, "No rendering backend available"), + Self::UnsupportedBackend(s) => write!(f, "Unsupported backend: {s}"), + Self::BackendInitFailed(s) => write!(f, "Backend initialization failed: {s}"), + Self::BackendError(s) => write!(f, "Backend error: {s}"), + Self::PipelineError(s) => write!(f, "Pipeline error: {s}"), + Self::ShapingError(s) => write!(f, "Text shaping failed: {s}"), + Self::DrawingError(s) => write!(f, "Drawing failed: {s}"), + Self::InvalidDrawCommand(s) => write!(f, "Invalid draw command: {s}"), + Self::EffectError(s) => write!(f, "Effect application failed: {s}"), + Self::CompositingError(s) => write!(f, "Compositing failed: {s}"), + Self::FontError(s) => write!(f, "Font error: {s}"), + Self::GpuError(s) => write!(f, "GPU error: {s}"), + #[cfg(target_arch = "wasm32")] + Self::WasmError(s) => write!(f, "WASM error: {s}"), + Self::ResourceLimitExceeded(s) => write!(f, "Resource limit exceeded: {s}"), + Self::InvalidScript(s) => write!(f, "Invalid script: {s}"), + Self::ParseError(s) => write!(f, "Parse error: {s}"), + Self::InvalidInput(s) => write!(f, "Invalid input: {s}"), + Self::CoreError(e) => write!(f, "Core error: {e}"), + Self::InitializationError(s) => write!(f, "Initialization error: {s}"), + Self::IOError(s) => write!(f, "IO error: {s}"), + Self::InvalidState(s) => write!(f, "Invalid state: {s}"), + Self::UnsupportedOperation(s) => write!(f, "Unsupported operation: {s}"), + } + } +} + +// Manual Error implementation for nostd +#[cfg(feature = "nostd")] +impl core::error::Error for RenderError {} diff --git a/crates/ass-renderer/src/utils/math.rs b/crates/ass-renderer/src/utils/math.rs new file mode 100644 index 0000000..24ee840 --- /dev/null +++ b/crates/ass-renderer/src/utils/math.rs @@ -0,0 +1,147 @@ +//! Math utilities for transformations and interpolation + +/// 3x3 transformation matrix +#[derive(Debug, Clone, Copy)] +pub struct Matrix3x3 { + /// Matrix elements in row-major order + pub m: [[f32; 3]; 3], +} + +impl Matrix3x3 { + /// Create identity matrix + pub fn identity() -> Self { + Self { + m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + } + } + + /// Create translation matrix + pub fn translate(x: f32, y: f32) -> Self { + Self { + m: [[1.0, 0.0, x], [0.0, 1.0, y], [0.0, 0.0, 1.0]], + } + } + + /// Create scale matrix + pub fn scale(sx: f32, sy: f32) -> Self { + Self { + m: [[sx, 0.0, 0.0], [0.0, sy, 0.0], [0.0, 0.0, 1.0]], + } + } + + /// Create rotation matrix (angle in radians) + pub fn rotate(angle: f32) -> Self { + let cos = angle.cos(); + let sin = angle.sin(); + Self { + m: [[cos, -sin, 0.0], [sin, cos, 0.0], [0.0, 0.0, 1.0]], + } + } + + /// Multiply two matrices + pub fn multiply(&self, other: &Self) -> Self { + let mut result = Self::identity(); + for i in 0..3 { + for j in 0..3 { + result.m[i][j] = 0.0; + for k in 0..3 { + result.m[i][j] += self.m[i][k] * other.m[k][j]; + } + } + } + result + } + + /// Transform a point + pub fn transform_point(&self, x: f32, y: f32) -> (f32, f32) { + let tx = self.m[0][0] * x + self.m[0][1] * y + self.m[0][2]; + let ty = self.m[1][0] * x + self.m[1][1] * y + self.m[1][2]; + (tx, ty) + } +} + +impl Default for Matrix3x3 { + fn default() -> Self { + Self::identity() + } +} + +/// 2D transformation +#[derive(Debug, Clone, Copy)] +pub struct Transform2D { + /// Translation X + pub tx: f32, + /// Translation Y + pub ty: f32, + /// Scale X + pub sx: f32, + /// Scale Y + pub sy: f32, + /// Rotation angle in radians + pub rotation: f32, + /// Shear X + pub shear_x: f32, + /// Shear Y + pub shear_y: f32, +} + +impl Transform2D { + /// Create identity transform + pub fn identity() -> Self { + Self { + tx: 0.0, + ty: 0.0, + sx: 1.0, + sy: 1.0, + rotation: 0.0, + shear_x: 0.0, + shear_y: 0.0, + } + } + + /// Convert to matrix + pub fn to_matrix(&self) -> Matrix3x3 { + let translate = Matrix3x3::translate(self.tx, self.ty); + let scale = Matrix3x3::scale(self.sx, self.sy); + let rotate = Matrix3x3::rotate(self.rotation); + + translate.multiply(&rotate).multiply(&scale) + } + + /// Interpolate between two transforms + pub fn lerp(&self, other: &Self, t: f32) -> Self { + Self { + tx: lerp(self.tx, other.tx, t), + ty: lerp(self.ty, other.ty, t), + sx: lerp(self.sx, other.sx, t), + sy: lerp(self.sy, other.sy, t), + rotation: lerp(self.rotation, other.rotation, t), + shear_x: lerp(self.shear_x, other.shear_x, t), + shear_y: lerp(self.shear_y, other.shear_y, t), + } + } +} + +impl Default for Transform2D { + fn default() -> Self { + Self::identity() + } +} + +/// Linear interpolation +pub fn lerp(a: f32, b: f32, t: f32) -> f32 { + a + (b - a) * t +} + +/// Cubic bezier interpolation (for animation curves) +pub fn cubic_bezier(t: f32, p1: f32, p2: f32) -> f32 { + // Using ass-core's eval_cubic_bezier would be better here + // This is a simplified version + let t2 = t * t; + let t3 = t2 * t; + let mt = 1.0 - t; + let mt2 = mt * mt; + let mt3 = mt2 * mt; + + mt3 * 0.0 + 3.0 * mt2 * t * p1 + 3.0 * mt * t2 * p2 + t3 * 1.0 +} diff --git a/crates/ass-renderer/src/utils/mod.rs b/crates/ass-renderer/src/utils/mod.rs new file mode 100644 index 0000000..919d97c --- /dev/null +++ b/crates/ass-renderer/src/utils/mod.rs @@ -0,0 +1,11 @@ +//! Utility types and helper functions + +mod caches; +mod errors; +mod math; +mod regions; + +pub use caches::{GlyphCache, TextureAtlas}; +pub use errors::RenderError; +pub use math::{cubic_bezier, lerp, Matrix3x3, Transform2D}; +pub use regions::DirtyRegion; diff --git a/crates/ass-renderer/src/utils/regions.rs b/crates/ass-renderer/src/utils/regions.rs new file mode 100644 index 0000000..f14ee8a --- /dev/null +++ b/crates/ass-renderer/src/utils/regions.rs @@ -0,0 +1,72 @@ +//! Dirty region tracking for incremental rendering + +/// Dirty region for incremental updates +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DirtyRegion { + /// Left coordinate + pub x: u32, + /// Top coordinate + pub y: u32, + /// Width + pub width: u32, + /// Height + pub height: u32, +} + +impl DirtyRegion { + /// Create a new dirty region + pub fn new(x: u32, y: u32, width: u32, height: u32) -> Self { + Self { + x, + y, + width, + height, + } + } + + /// Create a full screen dirty region + pub fn full_screen() -> Self { + Self { + x: 0, + y: 0, + width: u32::MAX, + height: u32::MAX, + } + } + + /// Check if this region intersects with bounds + pub fn intersects(&self, bounds: (u32, u32, u32, u32)) -> bool { + let (bx1, by1, bx2, by2) = bounds; + let rx1 = self.x; + let ry1 = self.y; + let rx2 = self.x.saturating_add(self.width); + let ry2 = self.y.saturating_add(self.height); + + !(rx2 < bx1 || rx1 > bx2 || ry2 < by1 || ry1 > by2) + } + + /// Merge with another region + pub fn merge(&self, other: &Self) -> Self { + let x1 = self.x.min(other.x); + let y1 = self.y.min(other.y); + let x2 = (self.x + self.width).max(other.x + other.width); + let y2 = (self.y + self.height).max(other.y + other.height); + + Self { + x: x1, + y: y1, + width: x2 - x1, + height: y2 - y1, + } + } + + /// Check if region contains point + pub fn contains_point(&self, x: u32, y: u32) -> bool { + x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height + } + + /// Get area of region + pub fn area(&self) -> u64 { + self.width as u64 * self.height as u64 + } +} diff --git a/crates/ass-renderer/tests/README.md b/crates/ass-renderer/tests/README.md new file mode 100644 index 0000000..fa6c546 --- /dev/null +++ b/crates/ass-renderer/tests/README.md @@ -0,0 +1,309 @@ +# Compatibility Testing Guide + +This document explains how to run comprehensive compatibility tests between our ass-renderer and libass. + +## Prerequisites + +### Install libass + +**macOS (Homebrew):** +```bash +brew install libass +``` + +**Ubuntu/Debian:** +```bash +sudo apt-get install libass-dev +``` + +**Fedora/RHEL:** +```bash +sudo dnf install libass-devel +``` + +**Windows:** +```bash +# Using vcpkg +vcpkg install libass +``` + +### Install Dependencies + +```bash +# Add image support for visual diffs +cargo add image --features png + +# Optional: serde for JSON output +cargo add serde --features derive +cargo add serde_json +``` + +## Running Tests + +### Basic Compatibility Tests + +Run all compatibility tests: +```bash +cargo test --features libass-compare +``` + +Run specific test categories: +```bash +# Basic text rendering +cargo test test_basic_text_compatibility --features libass-compare + +# Animation tests +cargo test test_animation_compatibility --features libass-compare + +# Transform tests +cargo test test_transform_compatibility --features libass-compare +``` + +### With Visual Diff Generation + +Enable visual diff output: +```bash +GENERATE_VISUAL_DIFFS=1 cargo test --features libass-compare,image +``` + +This will create PNG files in `test_output/compatibility/` showing: +- `{test_name}_ours.png` - Our renderer output +- `{test_name}_libass.png` - libass reference output +- `{test_name}_diff.png` - Difference heatmap + +### Performance Benchmarking + +Run with performance measurement: +```bash +cargo test --features libass-compare --release -- --nocapture +``` + +### Custom Test Configuration + +Create a custom test with specific tolerance: +```rust +let config = TestConfig { + pixel_tolerance: 3, // Allow 3 pixel value difference + significance_threshold: 0.01, // 1% difference threshold + generate_visual_diffs: true, + test_animations: true, + animation_step_cs: 5, // 50ms animation steps + ..Default::default() +}; +``` + +## Test Categories + +### 1. Basic Text Rendering +- Simple dialogue lines +- Font styling (bold, italic, underline) +- Color changes +- Font size variations + +### 2. Text Effects +- Outlines and shadows +- Blur effects +- Color overrides +- Spacing adjustments + +### 3. Animations +- Movement (`\move`) +- Fading (`\fad`, `\fade`) +- Transforms (`\t`) +- Karaoke effects (`\k`, `\kf`) + +### 4. Transformations +- Rotation (`\frx`, `\fry`, `\frz`) +- Scaling (`\fscx`, `\fscy`) +- Shearing (`\fax`, `\fay`) +- Origin points (`\org`) + +### 5. Drawing Commands +- Vector paths (`\p`) +- Bezier curves +- Clipping regions (`\clip`) + +### 6. Edge Cases +- Empty events +- Invalid parameters +- Off-screen content +- Malformed tags + +## Interpreting Results + +### Compatibility Metrics + +**Pixel Difference Percentage:** +- `0.000%` - Perfect match (pixel identical) +- `< 0.001%` - Excellent (sub-pixel differences) +- `< 0.01%` - Good (minor rasterization differences) +- `< 0.1%` - Acceptable (visible but minor differences) +- `> 0.1%` - Poor (significant visual differences) + +**Maximum Pixel Difference:** +- `0-2` - Excellent (imperceptible) +- `3-5` - Good (very minor) +- `6-15` - Acceptable (minor) +- `16+` - Poor (noticeable) + +### Common Difference Sources + +1. **Font Rasterization:** Different font rendering engines +2. **Antialiasing:** Slight differences in edge smoothing +3. **Color Blending:** Alpha blending precision differences +4. **Animation Timing:** Frame timing precision differences +5. **Coordinate Precision:** Sub-pixel positioning differences + +## Continuous Integration + +### GitHub Actions Example + +```yaml +name: Compatibility Tests + +on: [push, pull_request] + +jobs: + compatibility: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install libass + run: sudo apt-get install libass-dev + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + - name: Run compatibility tests + run: cargo test --features libass-compare + + - name: Upload visual diffs + if: failure() + uses: actions/upload-artifact@v3 + with: + name: visual-diffs + path: test_output/ +``` + +## Debugging Failed Tests + +### 1. Visual Inspection +Check the generated PNG files to see visual differences: +```bash +open test_output/compatibility/{test_name}_diff.png +``` + +### 2. Increase Tolerance +For minor differences, adjust tolerance: +```rust +let config = TestConfig { + pixel_tolerance: 5, // Increase from default + significance_threshold: 0.02, // Increase from 1% to 2% + ..Default::default() +}; +``` + +### 3. Animation Analysis +For animation tests, check individual frames: +```bash +# Run with detailed output +RUST_LOG=debug cargo test test_animation_compatibility --features libass-compare -- --nocapture +``` + +### 4. Performance Analysis +Compare rendering performance: +```bash +cargo test --features libass-compare --release -- --nocapture | grep "render time" +``` + +## Adding New Tests + +### Create Test Script +```rust +fn create_custom_test_script() -> String { + r#"[Script Info] +Title: Custom Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,Your test content here"#.to_string() +} +``` + +### Add to Test Suite +```rust +#[test] +#[cfg(feature = "libass-compare")] +fn test_custom_feature() { + let context = RenderContext::new(TEST_WIDTH, TEST_HEIGHT); + let config = TestConfig::default(); + let mut tester = CompatibilityTester::new(context, config).unwrap(); + + let script_content = create_custom_test_script(); + let script = Script::parse(&script_content).unwrap(); + let result = tester.test_script_compatibility(&script, "custom_feature").unwrap(); + + assert!(result.passed, "Custom test failed with {}% difference", + result.pixel_diff_percentage * 100.0); +} +``` + +## Performance Targets + +### Rendering Speed +- **Target:** Our renderer within 150% of libass speed +- **Acceptable:** Within 200% of libass speed +- **Poor:** More than 300% of libass speed + +### Memory Usage +- **Target:** Within 120% of libass memory usage +- **Acceptable:** Within 150% of libass memory usage +- **Poor:** More than 200% of libass memory usage + +### Compatibility +- **Target:** 95%+ tests pass with <0.01% difference +- **Acceptable:** 90%+ tests pass with <0.05% difference +- **Poor:** <85% tests pass or >0.1% average difference + +## Troubleshooting + +### libass Not Found +```bash +# Check if libass is installed +pkg-config --modversion libass + +# macOS specific +brew list libass + +# Ubuntu/Debian +dpkg -l | grep libass +``` + +### Build Errors +```bash +# Clean rebuild +cargo clean +cargo build --features libass-compare + +# Check feature flags +cargo build --features libass-compare --verbose +``` + +### Test Failures +```bash +# Run with maximum output +RUST_LOG=trace cargo test test_name --features libass-compare -- --nocapture + +# Generate visual diffs for debugging +mkdir -p test_output/compatibility +cargo test --features libass-compare,image +``` \ No newline at end of file diff --git a/crates/ass-renderer/tests/compatibility_tests.rs b/crates/ass-renderer/tests/compatibility_tests.rs new file mode 100644 index 0000000..692accf --- /dev/null +++ b/crates/ass-renderer/tests/compatibility_tests.rs @@ -0,0 +1,442 @@ +//! Comprehensive libass compatibility tests +//! +//! This test suite verifies pixel-perfect compatibility with libass +//! using a variety of ASS features and edge cases. + +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +use ass_core::parser::Script; +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +use ass_renderer::debug::{CompatibilityTestSuite, CompatibilityTester, TestConfig}; +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +use ass_renderer::renderer::RenderContext; + +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +const TEST_WIDTH: u32 = 1920; +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +const TEST_HEIGHT: u32 = 1080; + +/// Test basic text rendering compatibility +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_basic_text_compatibility() { + let context = RenderContext::new(TEST_WIDTH, TEST_HEIGHT); + let config = TestConfig { + pixel_tolerance: 2, // Allow slight differences due to different rasterizers + significance_threshold: 0.005, // 0.5% threshold + ..Default::default() + }; + + let mut tester = CompatibilityTester::new(context, config).expect("Failed to create tester"); + + let script_content = r#"[Script Info] +Title: Basic Text Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,Hello World!"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + let result = tester + .test_script_compatibility(&script, "basic_text") + .expect("Test failed"); + + assert!( + result.passed, + "Basic text test failed with {}% difference", + result.pixel_diff_percentage * 100.0 + ); +} + +/// Test various text effects and formatting +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_text_effects_compatibility() { + let context = RenderContext::new(TEST_WIDTH, TEST_HEIGHT); + let config = TestConfig::default(); + + let mut tester = CompatibilityTester::new(context, config).expect("Failed to create tester"); + + let script_content = r#"[Script Info] +Title: Text Effects Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\b1}Bold text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\i1}Italic text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\u1}Underlined text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\s1}Strikethrough text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\c&H00FF00&}Green text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\fs72}Large text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\fsp10}Spaced text"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + let result = tester + .test_script_compatibility(&script, "text_effects") + .expect("Test failed"); + + // Text effects might have more differences due to implementation differences + assert!( + result.pixel_diff_percentage < 0.05, + "Text effects test failed with {}% difference", + result.pixel_diff_percentage * 100.0 + ); +} + +/// Test animation and movement +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_animation_compatibility() { + let context = RenderContext::new(TEST_WIDTH, TEST_HEIGHT); + let config = TestConfig { + test_animations: true, + animation_step_cs: 10, // 100ms steps + max_animation_duration_cs: 500, // 5 seconds + ..Default::default() + }; + + let mut tester = CompatibilityTester::new(context, config).expect("Failed to create tester"); + + let script_content = r#"[Script Info] +Title: Animation Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,{\move(100,500,1800,500,0,5000)}Moving text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,{\fad(500,500)}Fading text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,{\t(0,2000,\fs72)}Growing text"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + let result = tester + .test_script_compatibility(&script, "animation") + .expect("Test failed"); + + // Animation might have timing differences, allow more tolerance + assert!( + result.pixel_diff_percentage < 0.1, + "Animation test failed with {}% difference", + result.pixel_diff_percentage * 100.0 + ); +} + +/// Test complex transformations +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_transform_compatibility() { + let context = RenderContext::new(TEST_WIDTH, TEST_HEIGHT); + let config = TestConfig::default(); + + let mut tester = CompatibilityTester::new(context, config).expect("Failed to create tester"); + + let script_content = r#"[Script Info] +Title: Transform Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\frz45}Rotated text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\fscx150\fscy75}Scaled text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\fax0.3}Sheared text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\org(960,540)\frz90}Rotated around center"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + let result = tester + .test_script_compatibility(&script, "transforms") + .expect("Test failed"); + + // Transforms are complex and may have implementation differences + assert!( + result.pixel_diff_percentage < 0.08, + "Transform test failed with {}% difference", + result.pixel_diff_percentage * 100.0 + ); +} + +/// Test karaoke effects +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_karaoke_compatibility() { + let context = RenderContext::new(TEST_WIDTH, TEST_HEIGHT); + let config = TestConfig { + test_animations: true, + animation_step_cs: 5, // 50ms steps for karaoke + max_animation_duration_cs: 1000, // 10 seconds + ..Default::default() + }; + + let mut tester = CompatibilityTester::new(context, config).expect("Failed to create tester"); + + let script_content = r#"[Script Info] +Title: Karaoke Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00FF0000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,karaoke,{\k30}Ka{\k30}ra{\k30}o{\k30}ke {\k30}test +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\kf50}Smooth {\kf50}karaoke"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + let result = tester + .test_script_compatibility(&script, "karaoke") + .expect("Test failed"); + + // Karaoke timing is complex, allow higher tolerance + assert!( + result.pixel_diff_percentage < 0.15, + "Karaoke test failed with {}% difference", + result.pixel_diff_percentage * 100.0 + ); +} + +/// Test drawing commands +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_drawing_compatibility() { + let context = RenderContext::new(TEST_WIDTH, TEST_HEIGHT); + let config = TestConfig::default(); + + let mut tester = CompatibilityTester::new(context, config).expect("Failed to create tester"); + + let script_content = r#"[Script Info] +Title: Drawing Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\p1}m 100 100 l 200 100 200 200 100 200{\p0} +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\p1}m 300 300 b 400 250 500 350 600 300{\p0}"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + let result = tester + .test_script_compatibility(&script, "drawing") + .expect("Test failed"); + + // Drawing commands might have differences in curve rendering + assert!( + result.pixel_diff_percentage < 0.1, + "Drawing test failed with {}% difference", + result.pixel_diff_percentage * 100.0 + ); +} + +/// Test edge cases and error handling +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_edge_cases_compatibility() { + let context = RenderContext::new(TEST_WIDTH, TEST_HEIGHT); + let config = TestConfig::default(); + + let mut tester = CompatibilityTester::new(context, config).expect("Failed to create tester"); + + let script_content = r#"[Script Info] +Title: Edge Cases Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,, +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\fs0}Zero size +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\pos(-100,-100)}Off screen +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\c&HINVALID&}Invalid color +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\unknown}Unknown tag"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + let result = tester + .test_script_compatibility(&script, "edge_cases") + .expect("Test failed"); + + // Edge cases should still maintain compatibility + assert!( + result.pixel_diff_percentage < 0.02, + "Edge cases test failed with {}% difference", + result.pixel_diff_percentage * 100.0 + ); +} + +/// Run comprehensive test suite +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_comprehensive_compatibility_suite() { + let context = RenderContext::new(TEST_WIDTH, TEST_HEIGHT); + let config = TestConfig { + generate_visual_diffs: true, + output_dir: "test_output/comprehensive".to_string(), + ..Default::default() + }; + + let mut test_suite = + CompatibilityTestSuite::new(context, config).expect("Failed to create test suite"); + + // Add various test cases + test_suite.add_test_case("basic_dialogue".to_string(), create_basic_dialogue_script()); + test_suite.add_test_case( + "complex_styling".to_string(), + create_complex_styling_script(), + ); + test_suite.add_test_case("multi_language".to_string(), create_multi_language_script()); + test_suite.add_test_case( + "performance_stress".to_string(), + create_performance_stress_script(), + ); + + let results = test_suite.run_all_tests().expect("Test suite failed"); + + // Check overall compatibility + let total_tests = results.len(); + let passed_tests = results.iter().filter(|r| r.passed).count(); + let avg_diff = + results.iter().map(|r| r.pixel_diff_percentage).sum::() / total_tests as f64; + + println!("Compatibility Test Results:"); + println!( + " Passed: {}/{} ({:.1}%)", + passed_tests, + total_tests, + (passed_tests as f64 / total_tests as f64) * 100.0 + ); + println!(" Average difference: {:.3}%", avg_diff * 100.0); + + // At least 80% of tests should pass + assert!( + passed_tests as f64 / total_tests as f64 >= 0.8, + "Less than 80% of compatibility tests passed" + ); + + // Average difference should be less than 2% + assert!( + avg_diff < 0.02, + "Average pixel difference too high: {:.3}%", + avg_diff * 100.0 + ); +} + +// Helper functions to create test scripts + +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn create_basic_dialogue_script() -> String { + r#"[Script Info] +Title: Basic Dialogue +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,This is a basic dialogue line. +Dialogue: 0,0:00:02.00,0:00:06.00,Default,,0,0,0,,This is another line with different timing."#.to_string() +} + +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn create_complex_styling_script() -> String { + r#"[Script Info] +Title: Complex Styling +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 +Style: Title,Arial,72,&H00FFFF00,&H000000FF,&H00000000,&H80000000,1,0,0,0,120,120,5,0,1,3,2,2,100,100,100,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Title,,0,0,0,,{\pos(960,200)}Main Title +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\c&H00FF00&\b1}Green bold text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{\blur2\c&HFF0000&}Blurred red text"#.to_string() +} + +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn create_multi_language_script() -> String { + r#"[Script Info] +Title: Multi Language +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,English text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,日本語テキスト +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,中文文本 +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,العربية النص"#.to_string() +} + +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn create_performance_stress_script() -> String { + let mut script = r#"[Script Info] +Title: Performance Stress +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"#.to_string(); + + // Add many concurrent dialogue lines + for i in 0..20 { + script.push_str(&format!( + "\nDialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,{{\\pos({},{})}}Line {}", + 100 + (i % 10) * 180, + 100 + (i / 10) * 100, + i + 1 + )); + } + + script +} + +// Stub tests when libass-compare is not available +#[cfg(not(feature = "libass-compare"))] +mod stub_tests { + #[test] + fn compatibility_tests_require_libass_feature() { + println!("Compatibility tests require 'libass-compare' feature to be enabled"); + println!("Install libass and run with: cargo test --features libass-compare"); + } +} diff --git a/crates/ass-renderer/tests/simple_compatibility_test.rs b/crates/ass-renderer/tests/simple_compatibility_test.rs new file mode 100644 index 0000000..c58cc22 --- /dev/null +++ b/crates/ass-renderer/tests/simple_compatibility_test.rs @@ -0,0 +1,169 @@ +//! Simple compatibility test without complex dependencies + +use ass_core::parser::Script; + +#[cfg(feature = "software-backend")] +use ass_renderer::backends::BackendType; +#[cfg(feature = "software-backend")] +use ass_renderer::renderer::{RenderContext, Renderer}; + +#[test] +fn test_minimal_script_parsing() { + // Test basic parsing functionality available in minimal builds + let script_content = r#"[Script Info] +Title: Minimal Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,Hello World!"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + + // Basic validation - this works without any backends + let sections = script.sections(); + assert!(!sections.is_empty()); + assert!(sections.len() >= 3); // Should have Script Info, Styles, and Events sections + + println!("✅ Minimal parsing test passed!"); +} + +#[test] +#[cfg(feature = "software-backend")] +fn test_basic_script_parsing() { + // Test that we can parse a basic script + let script_content = r#"[Script Info] +Title: Basic Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,Hello World!"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + + // Test that we can create a renderer + let context = RenderContext::new(1920, 1080); + let mut renderer = + Renderer::new(BackendType::Software, context).expect("Failed to create renderer"); + + // Test that we can render a frame + let frame = renderer + .render_frame(&script, 200) + .expect("Failed to render frame"); // 2 seconds + + // Basic validation + assert_eq!(frame.width(), 1920); + assert_eq!(frame.height(), 1080); + assert!(!frame.data().is_empty()); + + println!("✅ Basic rendering test passed!"); + println!("Frame size: {}x{}", frame.width(), frame.height()); + println!("Data length: {} bytes", frame.data().len()); +} + +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_libass_rendering() { + // Test that libass renderer works + use ass_renderer::debug::LibassRenderer; + + let script_content = r#"[Script Info] +Title: Basic Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,Hello World!"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + let mut libass_renderer = + LibassRenderer::new(1920, 1080).expect("Failed to create libass renderer"); + + let frame = libass_renderer + .render_frame(&script, 200) + .expect("Failed to render with libass"); + + assert_eq!(frame.width(), 1920); + assert_eq!(frame.height(), 1080); + + println!("✅ libass rendering test passed!"); + println!("Frame size: {}x{}", frame.width(), frame.height()); +} + +#[test] +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn test_basic_comparison() { + // Simple pixel comparison test + use ass_renderer::debug::LibassRenderer; + + let script_content = r#"[Script Info] +Title: Comparison Test +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,50,50,50,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,,0,0,0,,Test"#; + + let script = Script::parse(script_content).expect("Failed to parse script"); + + // Render with our renderer + let context = RenderContext::new(1920, 1080); + let mut our_renderer = + Renderer::new(BackendType::Software, context).expect("Failed to create our renderer"); + let our_frame = our_renderer + .render_frame(&script, 200) + .expect("Failed to render with our renderer"); + + // Render with libass + let mut libass_renderer = + LibassRenderer::new(1920, 1080).expect("Failed to create libass renderer"); + let libass_frame = libass_renderer + .render_frame(&script, 200) + .expect("Failed to render with libass"); + + // Basic comparison + assert_eq!(our_frame.width(), libass_frame.width()); + assert_eq!(our_frame.height(), libass_frame.height()); + + // Count non-transparent pixels in each frame + let our_pixels = count_non_transparent_pixels(our_frame.data()); + let libass_pixels = count_non_transparent_pixels(libass_frame.data()); + + println!("✅ Basic comparison test completed!"); + println!("Our renderer non-transparent pixels: {our_pixels}"); + println!("libass non-transparent pixels: {libass_pixels}"); + + // If both have some pixels, that's a good sign + // (We're not expecting pixel-perfect match yet) + if our_pixels > 0 || libass_pixels > 0 { + println!("✅ At least one renderer produced visible output"); + } +} + +#[cfg(all(feature = "libass-compare", not(feature = "nostd")))] +fn count_non_transparent_pixels(data: &[u8]) -> usize { + data.chunks(4) + .filter(|chunk| chunk.len() == 4 && chunk[3] > 0) // Alpha > 0 + .count() +} diff --git a/crates/vodplace-player-worker/Cargo.toml b/crates/vodplace-player-worker/Cargo.toml index cd574da..8a859fc 100644 --- a/crates/vodplace-player-worker/Cargo.toml +++ b/crates/vodplace-player-worker/Cargo.toml @@ -25,7 +25,14 @@ web-sys = { version = "0.3", features = [ "RequestMode", "Response", "Headers", + "OffscreenCanvas", + "OffscreenCanvasRenderingContext2d", + "ImageData", ] } +ass-renderer = { path = "../ass-renderer", default-features = false, features = ["web-backend", "analysis-integration"] } +ass-core = { path = "../ass-core" } +fontdb = "0.16" +getrandom = { version = "0.3", features = ["wasm_js"] } serde = { workspace = true } postcard = { workspace = true } console_error_panic_hook = "0.1" diff --git a/flake.nix b/flake.nix index deb82aa..849f647 100644 --- a/flake.nix +++ b/flake.nix @@ -66,6 +66,7 @@ pkg-config openssl ffmpeg_7.dev + libass ] ++ lib.optionals pkgs.stdenv.isDarwin [ # Additional darwin specific inputs can be set here