diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 9b459b9941..e5931d4187 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -614,6 +614,7 @@ "arbitrary_1.4.2": "{\"dependencies\":[{\"name\":\"derive_arbitrary\",\"optional\":true,\"req\":\"~1.4.0\"},{\"kind\":\"dev\",\"name\":\"exhaustigen\",\"req\":\"^0.1.0\"}],\"features\":{\"derive\":[\"derive_arbitrary\"]}}", "arboard_3.6.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"clipboard-win\",\"req\":\"^5.3.1\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10.2\"},{\"default_features\":false,\"features\":[\"png\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"default_features\":false,\"features\":[\"tiff\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"png\",\"bmp\"],\"name\":\"image\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(windows)\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"log\",\"req\":\"^0.4\",\"target\":\"cfg(windows)\"},{\"name\":\"objc2\",\"req\":\"^0.6.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"objc2-core-graphics\",\"NSPasteboard\",\"NSPasteboardItem\",\"NSImage\"],\"name\":\"objc2-app-kit\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CFCGTypes\"],\"name\":\"objc2-core-foundation\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"CGImage\",\"CGColorSpace\",\"CGDataProvider\"],\"name\":\"objc2-core-graphics\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"default_features\":false,\"features\":[\"std\",\"NSArray\",\"NSString\",\"NSEnumerator\",\"NSGeometry\",\"NSValue\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3.0\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_System_DataExchange\",\"Win32_System_Memory\",\"Win32_System_Ole\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.61.0\",\"target\":\"cfg(windows)\"},{\"name\":\"wl-clipboard-rs\",\"optional\":true,\"req\":\"^0.9.0\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"},{\"name\":\"x11rb\",\"req\":\"^0.13\",\"target\":\"cfg(all(unix, not(any(target_os=\\\"macos\\\", target_os=\\\"android\\\", target_os=\\\"emscripten\\\"))))\"}],\"features\":{\"core-graphics\":[\"dep:objc2-core-graphics\"],\"default\":[\"image-data\"],\"image\":[\"dep:image\"],\"image-data\":[\"dep:objc2-core-graphics\",\"dep:objc2-core-foundation\",\"image\",\"windows-sys\",\"core-graphics\"],\"wayland-data-control\":[\"wl-clipboard-rs\"],\"windows-sys\":[\"windows-sys/Win32_Graphics_Gdi\"],\"wl-clipboard-rs\":[\"dep:wl-clipboard-rs\"]}}", "arc-swap_1.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adaptive-barrier\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"~0.7\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"~0.8\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"~1\"},{\"kind\":\"dev\",\"name\":\"parking_lot\",\"req\":\"~0.12\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.177\"}],\"features\":{\"experimental-strategies\":[],\"experimental-thread-local\":[],\"internal-test-strategies\":[],\"weak\":[]}}", + "arrayref_0.3.9": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"}],\"features\":{}}", "arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}", "ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", @@ -847,6 +848,7 @@ "fnv_1.0.7": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "foldhash_0.1.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "foldhash_0.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rapidhash\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", + "font8x8_0.3.1": "{\"dependencies\":[],\"features\":{\"default\":[\"unicode\",\"std\"],\"std\":[],\"unicode\":[]}}", "foreign-types-shared_0.1.1": "{\"dependencies\":[],\"features\":{}}", "foreign-types_0.3.2": "{\"dependencies\":[{\"name\":\"foreign-types-shared\",\"req\":\"^0.1\"}],\"features\":{}}", "form_urlencoded_1.2.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"}],\"features\":{\"alloc\":[\"percent-encoding/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"percent-encoding/std\"]}}", @@ -1130,6 +1132,7 @@ "pkcs8_0.10.2": "{\"dependencies\":[{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"pkcs5\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"spki\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"3des\":[\"encryption\",\"pkcs5/3des\"],\"alloc\":[\"der/alloc\",\"der/zeroize\",\"spki/alloc\"],\"des-insecure\":[\"encryption\",\"pkcs5/des-insecure\"],\"encryption\":[\"alloc\",\"pkcs5/alloc\",\"pkcs5/pbes2\",\"rand_core\"],\"getrandom\":[\"rand_core/getrandom\"],\"pem\":[\"alloc\",\"der/pem\",\"spki/pem\"],\"sha1-insecure\":[\"encryption\",\"pkcs5/sha1-insecure\"],\"std\":[\"alloc\",\"der/std\",\"spki/std\"]}}", "pkg-config_0.3.32": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"}],\"features\":{}}", "plist_1.8.0": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"indexmap\",\"req\":\"^2.1.0\"},{\"name\":\"quick_xml\",\"package\":\"quick-xml\",\"req\":\"^0.38.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.8.21\"},{\"features\":[\"parsing\",\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.30\"}],\"features\":{\"default\":[\"serde\"],\"enable_unstable_features_that_may_break_with_minor_version_bumps\":[]}}", + "png_0.17.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"bitflags\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"byteorder\",\"req\":\"^1.5.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^3.0\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"name\":\"fdeflate\",\"req\":\"^0.3.3\"},{\"name\":\"flate2\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"getopts\",\"req\":\"^0.2.14\"},{\"default_features\":false,\"features\":[\"glutin\"],\"kind\":\"dev\",\"name\":\"glium\",\"req\":\"^0.32\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"features\":[\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"term\",\"req\":\"^1.0.1\"}],\"features\":{\"benchmarks\":[],\"unstable\":[\"crc32fast/nightly\"]}}", "png_0.18.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"byteorder\",\"req\":\"^1.5.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"fdeflate\",\"req\":\"^0.3.3\"},{\"name\":\"flate2\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"features\":[\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.2\"}],\"features\":{\"benchmarks\":[],\"unstable\":[\"crc32fast/nightly\"],\"zlib-rs\":[\"flate2/zlib-rs\"]}}", "polling_3.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"concurrent-queue\",\"req\":\"^2.2.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"hermit-abi\",\"req\":\"^0.5.0\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"features\":[\"event\",\"fs\",\"pipe\",\"process\",\"std\",\"time\"],\"name\":\"rustix\",\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"fuchsia\\\", target_os = \\\"vxworks\\\"))\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3.17\",\"target\":\"cfg(all(unix, not(target_os=\\\"vita\\\")))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_LibraryLoader\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "poly1305_0.8.0": "{\"dependencies\":[{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"opaque-debug\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"universal-hash\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"std\":[\"universal-hash/std\"]}}", @@ -1324,6 +1327,7 @@ "static_assertions_1.1.0": "{\"dependencies\":[],\"features\":{\"nightly\":[]}}", "stop-words_0.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"human_regex\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"serde_json\",\"req\":\"^1\"}],\"features\":{\"constructed\":[],\"default\":[\"iso\"],\"iso\":[],\"nltk\":[],\"unimplemented\":[]}}", "streaming-iterator_0.1.9": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", + "strict-num_0.1.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"float-cmp\",\"optional\":true,\"req\":\"^0.9\"}],\"features\":{\"approx-eq\":[\"float-cmp\"],\"default\":[\"approx-eq\"]}}", "string_cache_0.8.9": "{\"dependencies\":[{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"new_debug_unreachable\",\"req\":\"^1.0.2\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"phf_shared\",\"req\":\"^0.11\"},{\"name\":\"precomputed-hash\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[\"serde_support\"],\"serde_support\":[\"serde\"]}}", "string_cache_codegen_0.5.4": "{\"dependencies\":[{\"name\":\"phf_generator\",\"req\":\"^0.11\"},{\"name\":\"phf_shared\",\"req\":\"^0.11\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"}],\"features\":{}}", "stringprep_0.1.5": "{\"dependencies\":[{\"name\":\"unicode-bidi\",\"req\":\"^0.3\"},{\"name\":\"unicode-normalization\",\"req\":\"^0.1\"},{\"name\":\"unicode-properties\",\"req\":\"^0.1.1\"}],\"features\":{}}", @@ -1371,6 +1375,8 @@ "time-macros_0.2.27": "{\"dependencies\":[{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"}],\"features\":{\"formatting\":[],\"large-dates\":[],\"parsing\":[],\"serde\":[]}}", "time_0.3.47": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8.1\",\"target\":\"cfg(bench)\"},{\"features\":[\"powerfmt\"],\"name\":\"deranged\",\"req\":\"^0.5.2\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"num_threads\",\"optional\":true,\"req\":\"^0.1.2\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"default_features\":false,\"name\":\"powerfmt\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"rand08\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand08\",\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"rand09\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand09\",\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.184\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.126\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"},{\"name\":\"time-macros\",\"optional\":true,\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"time-macros\",\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.102\",\"target\":\"cfg(__ui_tests)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"formatting\":[\"dep:itoa\",\"std\",\"time-macros?/formatting\"],\"large-dates\":[\"time-core/large-dates\",\"time-macros?/large-dates\"],\"local-offset\":[\"std\",\"dep:libc\",\"dep:num_threads\"],\"macros\":[\"dep:time-macros\"],\"parsing\":[\"time-macros?/parsing\"],\"quickcheck\":[\"dep:quickcheck\",\"alloc\",\"deranged/quickcheck\"],\"rand\":[\"rand08\",\"rand09\"],\"rand08\":[\"dep:rand08\",\"deranged/rand08\"],\"rand09\":[\"dep:rand09\",\"deranged/rand09\"],\"serde\":[\"dep:serde_core\",\"time-macros?/serde\",\"deranged/serde\"],\"serde-human-readable\":[\"serde\",\"formatting\",\"parsing\"],\"serde-well-known\":[\"serde\",\"formatting\",\"parsing\"],\"std\":[\"alloc\"],\"wasm-bindgen\":[\"dep:js-sys\"]}}", "tiny-keccak_2.0.2": "{\"dependencies\":[{\"name\":\"crunchy\",\"req\":\"^0.2.2\"}],\"features\":{\"cshake\":[],\"default\":[],\"fips202\":[\"keccak\",\"shake\",\"sha3\"],\"k12\":[],\"keccak\":[],\"kmac\":[\"cshake\"],\"parallel_hash\":[\"cshake\"],\"sha3\":[],\"shake\":[],\"sp800\":[\"cshake\",\"kmac\",\"tuple_hash\"],\"tuple_hash\":[\"cshake\"]}}", + "tiny-skia-path_0.11.4": "{\"dependencies\":[{\"name\":\"arrayref\",\"req\":\"^0.3.6\"},{\"name\":\"bytemuck\",\"req\":\"^1.4\"},{\"name\":\"libm\",\"optional\":true,\"req\":\"^0.2.1\"},{\"default_features\":false,\"name\":\"strict-num\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"std\"],\"no-std-float\":[\"libm\"],\"std\":[]}}", + "tiny-skia_0.11.4": "{\"dependencies\":[{\"name\":\"arrayref\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"name\":\"arrayvec\",\"req\":\"^0.7\"},{\"features\":[\"aarch64_simd\"],\"name\":\"bytemuck\",\"req\":\"^1.12\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"png\",\"optional\":true,\"req\":\"^0.17\"},{\"default_features\":false,\"name\":\"tiny-skia-path\",\"req\":\"^0.11.4\"}],\"features\":{\"default\":[\"std\",\"simd\",\"png-format\"],\"no-std-float\":[\"tiny-skia-path/no-std-float\"],\"png-format\":[\"std\",\"png\"],\"simd\":[],\"std\":[\"tiny-skia-path/std\"]}}", "tiny_http_0.12.0": "{\"dependencies\":[{\"name\":\"ascii\",\"req\":\"^1.0\"},{\"name\":\"chunked_transfer\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"fdlimit\",\"req\":\"^0.1\"},{\"name\":\"httpdate\",\"req\":\"^1.0.2\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rustc-serialize\",\"req\":\"^0.3\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.20\"},{\"name\":\"rustls-pemfile\",\"optional\":true,\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.6.0\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[],\"ssl\":[\"ssl-openssl\"],\"ssl-openssl\":[\"openssl\",\"zeroize\"],\"ssl-rustls\":[\"rustls\",\"rustls-pemfile\",\"zeroize\"]}}", "tinystr_0.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"std\":[],\"zerovec\":[\"dep:zerovec\"]}}", "tinyvec_1.10.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"generic-array\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"smallvec\",\"req\":\"^1\"},{\"name\":\"tinyvec_macros\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"alloc\":[\"tinyvec_macros\"],\"debugger_visualizer\":[],\"default\":[],\"experimental_write_impl\":[],\"grab_spare_slice\":[],\"latest_stable_rust\":[\"rustc_1_61\"],\"nightly_slice_partition_dedup\":[],\"real_blackbox\":[\"criterion/real_blackbox\"],\"rustc_1_40\":[],\"rustc_1_55\":[],\"rustc_1_57\":[],\"rustc_1_61\":[\"rustc_1_57\"],\"std\":[\"alloc\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 62d2ffae3b..778735bd24 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -459,6 +459,12 @@ dependencies = [ "rustversion", ] +[[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" @@ -1545,6 +1551,7 @@ name = "codex-artifact-presentation" version = "0.0.0" dependencies = [ "base64 0.22.1", + "font8x8", "image", "ppt-rs", "pretty_assertions", @@ -1553,6 +1560,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.18", + "tiny-skia", "tiny_http", "uuid", "zip 2.4.2", @@ -4088,6 +4096,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font8x8" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" + [[package]] name = "foreign-types" version = "0.3.2" @@ -5085,7 +5099,7 @@ dependencies = [ "image-webp", "moxcms", "num-traits", - "png", + "png 0.18.0", "tiff", "zune-core 0.5.1", "zune-jpeg 0.5.12", @@ -7029,6 +7043,19 @@ dependencies = [ "time", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "png" version = "0.18.0" @@ -9512,6 +9539,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[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" @@ -9991,6 +10024,32 @@ dependencies = [ "crunchy", ] +[[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 0.17.16", + "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_http" version = "0.12.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9ee05e21b6..1f4c1b9648 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -218,6 +218,8 @@ os_info = "3.12.0" owo-colors = "4.3.0" path-absolutize = "3.1.1" pathdiff = "0.2" +font8x8 = "0.3.1" +tiny-skia = "0.11.4" portable-pty = "0.9.0" ppt-rs = "0.2.6" predicates = "3" diff --git a/codex-rs/artifact-presentation/Cargo.toml b/codex-rs/artifact-presentation/Cargo.toml index 898beb40f0..b30809f9e9 100644 --- a/codex-rs/artifact-presentation/Cargo.toml +++ b/codex-rs/artifact-presentation/Cargo.toml @@ -13,11 +13,13 @@ workspace = true [dependencies] base64 = { workspace = true } +font8x8 = { workspace = true } image = { workspace = true, features = ["jpeg", "png"] } ppt-rs = { workspace = true } reqwest = { workspace = true, features = ["blocking"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tiny-skia = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true, features = ["v4"] } zip = { workspace = true } diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/api.rs b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs index 5680bf97fe..65fd637687 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/api.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs @@ -40,7 +40,6 @@ use std::io::Seek; use std::io::Write; use std::path::Path; use std::path::PathBuf; -use std::process::Command; use thiserror::Error; use uuid::Uuid; use zip::ZipArchive; @@ -75,6 +74,8 @@ pub enum PresentationArtifactError { ImportFailed { path: PathBuf, message: String }, #[error("failed to export PPTX `{path}`: {message}")] ExportFailed { path: PathBuf, message: String }, + #[error("failed to render preview for action `{action}`: {message}")] + RenderFailed { action: String, message: String }, } #[derive(Debug, Clone, Deserialize)] diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/args.rs b/codex-rs/artifact-presentation/src/presentation_artifact/args.rs index a139dcb055..39966935f0 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/args.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/args.rs @@ -24,6 +24,13 @@ struct ExportPreviewArgs { quality: Option, } +#[derive(Debug, Default, Deserialize)] +struct RenderPreviewArgs { + slide_index: Option, + scale: Option, + include_background: Option, +} + #[derive(Debug, Default, Deserialize)] struct AddSlideArgs { layout: Option, diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs index 8539b62c76..86ba674f4a 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs @@ -100,6 +100,7 @@ impl PresentationArtifactManager { "import_pptx" => self.import_pptx(request, cwd), "export_pptx" => self.export_pptx(request, cwd), "export_preview" => self.export_preview(request, cwd), + "render_preview" => self.render_preview(request), "get_summary" => self.get_summary(request), "list_slides" => self.list_slides(request), "list_layouts" => self.list_layouts(request), @@ -286,82 +287,39 @@ impl PresentationArtifactManager { parse_preview_output_format(args.format.as_deref(), &output_path, &request.action)?; let scale = normalize_preview_scale(args.scale, &request.action)?; let quality = normalize_preview_quality(args.quality, &request.action)?; - if let Some(parent) = output_path.parent() { - std::fs::create_dir_all(parent).map_err(|error| { - PresentationArtifactError::ExportFailed { - path: output_path.clone(), - message: error.to_string(), + let mut exported_paths = Vec::new(); + if let Some(slide_index) = args.slide_index { + if let Some(parent) = output_path.parent() { + std::fs::create_dir_all(parent).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: output_path.clone(), + message: error.to_string(), + } + })?; + } + let slide_index = usize::try_from(slide_index).map_err(|_| { + PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: "`slide_index` does not fit in usize".to_string(), } })?; - } - let temp_dir = - std::env::temp_dir().join(format!("presentation_preview_{}", Uuid::new_v4().simple())); - std::fs::create_dir_all(&temp_dir).map_err(|error| { - PresentationArtifactError::ExportFailed { - path: output_path.clone(), - message: error.to_string(), - } - })?; - let preview_document = if let Some(slide_index) = args.slide_index { - let slide = document - .slides - .get(slide_index as usize) - .cloned() - .ok_or_else(|| { - index_out_of_range(&request.action, slide_index as usize, document.slides.len()) - })?; - let slide_id = slide.slide_id.clone(); - PresentationDocument { - artifact_id: document.artifact_id.clone(), - name: document.name.clone(), - slide_size: document.slide_size, - theme: document.theme.clone(), - custom_text_styles: document.custom_text_styles.clone(), - layouts: Vec::new(), - slides: vec![slide], - active_slide_index: Some(0), - comment_self: document.comment_self.clone(), - comment_threads: document - .comment_threads - .iter() - .filter(|thread| match &thread.target { - CommentTarget::Slide { slide_id: target_slide_id } - | CommentTarget::Element { slide_id: target_slide_id, .. } - | CommentTarget::TextRange { slide_id: target_slide_id, .. } => { - target_slide_id == &slide_id - } - }) - .cloned() - .collect(), - next_slide_seq: 1, - next_element_seq: 1, - next_layout_seq: 1, - next_text_range_seq: document.next_text_range_seq, - next_comment_thread_seq: document.next_comment_thread_seq, - next_comment_message_seq: document.next_comment_message_seq, - } - } else { - document.clone() - }; - write_preview_images(&preview_document, &temp_dir, &request.action)?; - let mut exported_paths = collect_pngs(&temp_dir)?; - if args.slide_index.is_some() { - let rendered = - exported_paths - .pop() - .ok_or_else(|| PresentationArtifactError::ExportFailed { - path: output_path.clone(), - message: "preview renderer produced no images".to_string(), - })?; - write_preview_image( - &rendered, + let png_bytes = render_slide_png( + document, + slide_index, + RenderOptions { + scale: 1.0, + include_background: true, + }, + )?; + write_preview_image_bytes( + &png_bytes, &output_path, preview_format, scale, quality, &request.action, )?; - exported_paths = vec![output_path]; + exported_paths.push(output_path); } else { std::fs::create_dir_all(&output_path).map_err(|error| { PresentationArtifactError::ExportFailed { @@ -369,30 +327,27 @@ impl PresentationArtifactManager { message: error.to_string(), } })?; - let mut relocated = Vec::new(); - for rendered in exported_paths { - let filename = rendered.file_name().ok_or_else(|| { - PresentationArtifactError::ExportFailed { - path: output_path.clone(), - message: "rendered preview had no filename".to_string(), - } - })?; - let stem = Path::new(filename) - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("preview"); - let target = output_path.join(format!("{stem}.{}", preview_format.extension())); - write_preview_image( - &rendered, + for slide_index in 0..document.slides.len() { + let png_bytes = render_slide_png( + document, + slide_index, + RenderOptions { + scale: 1.0, + include_background: true, + }, + )?; + let target = + output_path.join(format!("slide-{}.{}", slide_index + 1, preview_format.extension())); + write_preview_image_bytes( + &png_bytes, &target, preview_format, scale, quality, &request.action, )?; - relocated.push(target); + exported_paths.push(target); } - exported_paths = relocated; } let mut response = PresentationArtifactResponse::new( artifact_id, @@ -404,6 +359,36 @@ impl PresentationArtifactManager { Ok(response) } + fn render_preview( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: RenderPreviewArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let slide_index = resolve_render_slide_index(document, args.slide_index, &request.action)?; + let png_bytes = render_slide_png( + document, + slide_index, + RenderOptions { + scale: normalize_preview_scale(args.scale, &request.action)?, + include_background: args.include_background.unwrap_or(true), + }, + )?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Rendered preview for slide {}", slide_index + 1), + snapshot_for_document(document), + ); + response.rendered_preview = Some(RenderedPreview { + slide_index, + png_bytes, + }); + response.active_slide_index = document.active_slide_index; + Ok(response) + } + fn get_summary( &mut self, request: PresentationArtifactRequest, @@ -3031,6 +3016,7 @@ impl PresentationArtifactManager { proto_json: None, patch: None, active_slide_index: None, + rendered_preview: None, }) } diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs b/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs index 8497062a91..302230e373 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/mod.rs @@ -8,3 +8,4 @@ include!("proto.rs"); include!("inspect.rs"); include!("pptx.rs"); include!("snapshot.rs"); +include!("render.rs"); diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs index efca31a99b..58bfa43b91 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs @@ -33,6 +33,7 @@ fn is_read_only_action(action: &str) -> bool { | "get_style" | "describe_styles" | "record_patch" + | "render_preview" ) } @@ -40,7 +41,7 @@ fn tracks_history(action: &str) -> bool { !is_read_only_action(action) && !matches!( action, - "export_pptx" | "export_preview" | "undo" | "redo" | "apply_patch" + "export_pptx" | "export_preview" | "render_preview" | "undo" | "redo" | "apply_patch" ) } diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs index a06abc7017..e4ff48f520 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs @@ -715,94 +715,8 @@ fn slide_table_xml(slide: &PresentationSlide) -> String { .join("\n") } -fn write_preview_images( - document: &PresentationDocument, - output_dir: &Path, - action: &str, -) -> Result<(), PresentationArtifactError> { - let pptx_path = output_dir.join("preview.pptx"); - let bytes = build_pptx_bytes(document, action).map_err(|message| { - PresentationArtifactError::ExportFailed { - path: pptx_path.clone(), - message, - } - })?; - std::fs::write(&pptx_path, bytes).map_err(|error| PresentationArtifactError::ExportFailed { - path: pptx_path.clone(), - message: error.to_string(), - })?; - render_pptx_to_pngs(&pptx_path, output_dir, action) -} - -fn render_pptx_to_pngs( - pptx_path: &Path, - output_dir: &Path, - action: &str, -) -> Result<(), PresentationArtifactError> { - let soffice_cmd = if cfg!(target_os = "macos") - && Path::new("/Applications/LibreOffice.app/Contents/MacOS/soffice").exists() - { - "/Applications/LibreOffice.app/Contents/MacOS/soffice" - } else { - "soffice" - }; - let conversion = Command::new(soffice_cmd) - .arg("--headless") - .arg("--convert-to") - .arg("pdf") - .arg(pptx_path) - .arg("--outdir") - .arg(output_dir) - .output() - .map_err(|error| PresentationArtifactError::ExportFailed { - path: pptx_path.to_path_buf(), - message: format!("{action}: failed to execute LibreOffice: {error}"), - })?; - if !conversion.status.success() { - return Err(PresentationArtifactError::ExportFailed { - path: pptx_path.to_path_buf(), - message: format!( - "{action}: LibreOffice conversion failed: {}", - String::from_utf8_lossy(&conversion.stderr) - ), - }); - } - - let pdf_path = output_dir.join( - pptx_path - .file_stem() - .and_then(|stem| stem.to_str()) - .map(|stem| format!("{stem}.pdf")) - .ok_or_else(|| PresentationArtifactError::ExportFailed { - path: pptx_path.to_path_buf(), - message: format!("{action}: preview pptx filename is invalid"), - })?, - ); - let prefix = output_dir.join("slide"); - let conversion = Command::new("pdftoppm") - .arg("-png") - .arg(&pdf_path) - .arg(&prefix) - .output() - .map_err(|error| PresentationArtifactError::ExportFailed { - path: pdf_path.clone(), - message: format!("{action}: failed to execute pdftoppm: {error}"), - })?; - std::fs::remove_file(&pdf_path).ok(); - if !conversion.status.success() { - return Err(PresentationArtifactError::ExportFailed { - path: output_dir.to_path_buf(), - message: format!( - "{action}: pdftoppm conversion failed: {}", - String::from_utf8_lossy(&conversion.stderr) - ), - }); - } - Ok(()) -} - -pub(crate) fn write_preview_image( - source_path: &Path, +pub(crate) fn write_preview_image_bytes( + png_bytes: &[u8], target_path: &Path, format: PreviewOutputFormat, scale: f32, @@ -810,7 +724,7 @@ pub(crate) fn write_preview_image( action: &str, ) -> Result<(), PresentationArtifactError> { if matches!(format, PreviewOutputFormat::Png) && scale == 1.0 { - std::fs::rename(source_path, target_path).map_err(|error| { + std::fs::write(target_path, png_bytes).map_err(|error| { PresentationArtifactError::ExportFailed { path: target_path.to_path_buf(), message: error.to_string(), @@ -818,11 +732,12 @@ pub(crate) fn write_preview_image( })?; return Ok(()); } - let mut preview = - image::open(source_path).map_err(|error| PresentationArtifactError::ExportFailed { - path: source_path.to_path_buf(), + let mut preview = image::load_from_memory(png_bytes).map_err(|error| { + PresentationArtifactError::ExportFailed { + path: target_path.to_path_buf(), message: format!("{action}: {error}"), - })?; + } + })?; if scale != 1.0 { let width = (preview.width() as f32 * scale).round().max(1.0) as u32; let height = (preview.height() as f32 * scale).round().max(1.0) as u32; @@ -880,24 +795,9 @@ pub(crate) fn write_preview_image( })?; } } - std::fs::remove_file(source_path).ok(); Ok(()) } -fn collect_pngs(output_dir: &Path) -> Result, PresentationArtifactError> { - let mut files = std::fs::read_dir(output_dir) - .map_err(|error| PresentationArtifactError::ExportFailed { - path: output_dir.to_path_buf(), - message: error.to_string(), - })? - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("png")) - .collect::>(); - files.sort(); - Ok(files) -} - fn parse_preview_output_format( format: Option<&str>, path: &Path, diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/render.rs b/codex-rs/artifact-presentation/src/presentation_artifact/render.rs new file mode 100644 index 0000000000..22da641013 --- /dev/null +++ b/codex-rs/artifact-presentation/src/presentation_artifact/render.rs @@ -0,0 +1,1854 @@ +use font8x8::BASIC_FONTS; +use font8x8::UnicodeFonts; +use image::DynamicImage; +use image::Rgba; +use image::RgbaImage; +use std::f32::consts::PI; +use tiny_skia::FillRule; +use tiny_skia::LineCap; +use tiny_skia::LineJoin; +use tiny_skia::Paint; +use tiny_skia::PathBuilder; +use tiny_skia::Pixmap; +use tiny_skia::Stroke; +use tiny_skia::Path as TinyPath; +use tiny_skia::Transform; + +const DEFAULT_BACKGROUND_HEX: &str = "FFFFFF"; +const DEFAULT_TEXT_HEX: &str = "000000"; +const DEFAULT_LINK_HEX: &str = "1155CC"; +const DEFAULT_SHAPE_STROKE_HEX: &str = "666666"; +const DEFAULT_TABLE_GRID_HEX: &str = "BFC5CC"; +const DEFAULT_COMMENT_HEX: &str = "F3C83C"; +const DEFAULT_TEXT_INSET: u32 = 6; +const MIN_TEXT_PIXELS: u32 = 8; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct RenderOptions { + pub scale: f32, + pub include_background: bool, +} + +#[derive(Clone)] +struct StyledGlyph { + ch: char, + style: TextStyle, + is_link: bool, +} + +struct TextLine { + glyphs: Vec, + width: u32, + spaces: usize, +} + +#[derive(Clone, Copy)] +struct TextRenderConfig { + width: u32, + font_px: u32, + line_height: u32, + wrap: TextWrapMode, + vertical_alignment: TextVerticalAlignment, + alignment: TextAlignment, +} + +#[derive(Clone, Copy)] +struct TextBounds { + left: u32, + top: u32, + width: u32, + height: u32, +} + +fn render_slide_png( + document: &PresentationDocument, + slide_index: usize, + options: RenderOptions, +) -> Result, PresentationArtifactError> { + if !options.scale.is_finite() || options.scale <= 0.0 { + return Err(PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "`scale` must be a positive number".to_string(), + }); + } + + let slide = document + .slides + .get(slide_index) + .ok_or_else(|| index_out_of_range("render_preview", slide_index, document.slides.len()))?; + let width = scaled_dimension(document.slide_size.width, options.scale); + let height = scaled_dimension(document.slide_size.height, options.scale); + let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0])); + + if options.include_background { + fill_rect( + &mut canvas, + 0, + 0, + width, + height, + slide_background_rgba(slide, document), + ); + } + + let mut ordered = slide.elements.iter().collect::>(); + ordered.sort_by_key(|element| element.z_order()); + for element in ordered { + render_element(&mut canvas, document, slide, element, options.scale)?; + } + + render_comment_overlays(&mut canvas, document, slide, options.scale); + + let mut output = std::io::Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(canvas) + .write_to(&mut output, image::ImageFormat::Png) + .map_err(|error| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: error.to_string(), + })?; + Ok(output.into_inner()) +} + +fn render_element( + canvas: &mut RgbaImage, + document: &PresentationDocument, + slide: &PresentationSlide, + element: &PresentationElement, + scale: f32, +) -> Result<(), PresentationArtifactError> { + match element { + PresentationElement::Text(text) => { + let frame = scaled_rect(text.frame, scale); + let mut image = + RgbaImage::from_pixel(frame.width.max(1), frame.height.max(1), Rgba([0, 0, 0, 0])); + if let Some(fill) = &text.fill { + let image_width = image.width(); + let image_height = image.height(); + fill_rect(&mut image, 0, 0, image_width, image_height, parse_rgba(fill, 255)); + } + draw_text_box( + &mut image, + &text.text, + &text.style, + &text.rich_text, + text.hyperlink.as_ref(), + scale, + ); + blend_image(canvas, &image, frame.left, frame.top); + } + PresentationElement::Shape(shape) => { + let frame = scaled_rect(shape.frame, scale); + let mut image = + RgbaImage::from_pixel(frame.width.max(1), frame.height.max(1), Rgba([0, 0, 0, 0])); + let vector = render_vector_image(image.width(), image.height(), |pixmap| { + render_shape_vector(pixmap, shape, scale) + })?; + blend_image(&mut image, &vector, 0, 0); + if let Some(text) = &shape.text { + let mut text_layer = RgbaImage::from_pixel( + image.width(), + image.height(), + Rgba([0, 0, 0, 0]), + ); + let rich_text = shape.rich_text.clone().unwrap_or_default(); + draw_text_box( + &mut text_layer, + text, + &shape.text_style, + &rich_text, + shape.hyperlink.as_ref(), + scale, + ); + blend_image(&mut image, &text_layer, 0, 0); + } + blend_transformed_image( + canvas, + &image, + frame.left, + frame.top, + shape.rotation_degrees.unwrap_or(0) as f32, + shape.flip_horizontal, + shape.flip_vertical, + ); + } + PresentationElement::Connector(connector) => { + let (left, top, width, height) = scaled_connector_bounds(connector, scale); + let image = render_vector_image(width.max(1), height.max(1), |pixmap| { + render_connector_vector(pixmap, connector, scale) + })?; + blend_image(canvas, &image, left, top); + if let Some(label) = &connector.label { + let mut label_image = RgbaImage::from_pixel( + width.max(1), + height.max(1), + Rgba([0, 0, 0, 0]), + ); + draw_text_box( + &mut label_image, + label, + &TextStyle { + font_size: Some(12), + color: Some(DEFAULT_TEXT_HEX.to_string()), + ..TextStyle::default() + }, + &RichTextState::default(), + None, + scale, + ); + blend_image(canvas, &label_image, left, top); + } + } + PresentationElement::Image(image) => { + let frame = scaled_rect(image.frame, scale); + let rendered = render_image_element(image, scale)?; + blend_transformed_image( + canvas, + &rendered, + frame.left, + frame.top, + image.rotation_degrees.unwrap_or(0) as f32, + image.flip_horizontal, + image.flip_vertical, + ); + } + PresentationElement::Table(table) => { + let frame = scaled_rect(table.frame, scale); + let rendered = render_table_element(table, document, scale)?; + blend_image(canvas, &rendered, frame.left, frame.top); + } + PresentationElement::Chart(chart) => { + let frame = scaled_rect(chart.frame, scale); + let rendered = render_chart_element(chart, document, scale)?; + blend_image(canvas, &rendered, frame.left, frame.top); + } + } + + let _ = slide; + Ok(()) +} + +fn render_shape_vector( + pixmap: &mut Pixmap, + shape: &ShapeElement, + scale: f32, +) -> Result<(), PresentationArtifactError> { + let path = shape_path(shape.geometry, pixmap.width(), pixmap.height())?; + if let Some(fill) = &shape.fill { + let mut paint = Paint::default(); + set_paint_color(&mut paint, parse_rgba(fill, 255)); + pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + } + if let Some(stroke) = &shape.stroke { + let mut paint = Paint::default(); + set_paint_color(&mut paint, parse_rgba(&stroke.color, 255)); + let style = Stroke { + width: (stroke.width.max(1) as f32 * scale).max(1.0), + line_cap: LineCap::Round, + line_join: LineJoin::Round, + ..Stroke::default() + }; + pixmap.stroke_path(&path, &paint, &style, Transform::identity(), None); + } else if shape.fill.is_none() { + let mut paint = Paint::default(); + set_paint_color(&mut paint, parse_rgba(DEFAULT_SHAPE_STROKE_HEX, 255)); + let style = Stroke { + width: scale.max(1.0), + line_cap: LineCap::Round, + line_join: LineJoin::Round, + ..Stroke::default() + }; + pixmap.stroke_path(&path, &paint, &style, Transform::identity(), None); + } + Ok(()) +} + +fn render_connector_vector( + pixmap: &mut Pixmap, + connector: &ConnectorElement, + scale: f32, +) -> Result<(), PresentationArtifactError> { + let (left, top, width, height) = scaled_connector_bounds(connector, scale); + let start = ( + (connector.start.left as f32 * scale) - left as f32, + (connector.start.top as f32 * scale) - top as f32, + ); + let end = ( + (connector.end.left as f32 * scale) - left as f32, + (connector.end.top as f32 * scale) - top as f32, + ); + let path = connector_path(connector.connector_type, start, end)?; + let mut paint = Paint::default(); + set_paint_color(&mut paint, parse_rgba(&connector.line.color, 255)); + let stroke = Stroke { + width: (connector.line.width.max(1) as f32 * scale).max(1.0), + line_cap: LineCap::Round, + line_join: LineJoin::Round, + ..Stroke::default() + }; + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + + let arrow_px = match connector.arrow_size { + ConnectorArrowScale::Small => 8.0 * scale, + ConnectorArrowScale::Medium => 12.0 * scale, + ConnectorArrowScale::Large => 16.0 * scale, + }; + draw_arrow_head(pixmap, end, start, connector.end_arrow, arrow_px, &connector.line.color)?; + draw_arrow_head( + pixmap, + start, + end, + connector.start_arrow, + arrow_px, + &connector.line.color, + )?; + let _ = width; + let _ = height; + Ok(()) +} + +fn render_image_element( + image: &ImageElement, + scale: f32, +) -> Result { + let frame = scaled_rect(image.frame, scale); + let mut canvas = RgbaImage::from_pixel(frame.width.max(1), frame.height.max(1), Rgba([0, 0, 0, 0])); + let Some(payload) = &image.payload else { + let border = render_vector_image(canvas.width(), canvas.height(), |pixmap| { + let path = shape_path(ShapeGeometry::Rectangle, pixmap.width(), pixmap.height())?; + let mut paint = Paint::default(); + set_paint_color(&mut paint, parse_rgba(DEFAULT_SHAPE_STROKE_HEX, 255)); + let stroke = Stroke { + width: scale.max(1.0), + ..Stroke::default() + }; + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + Ok(()) + })?; + blend_image(&mut canvas, &border, 0, 0); + draw_text_box( + &mut canvas, + image.prompt.as_deref().unwrap_or("Image placeholder"), + &TextStyle { + font_size: Some(14), + color: Some(DEFAULT_TEXT_HEX.to_string()), + alignment: Some(TextAlignment::Center), + ..TextStyle::default() + }, + &RichTextState { + layout: TextLayoutState { + vertical_alignment: Some(TextVerticalAlignment::Middle), + ..TextLayoutState::default() + }, + ..RichTextState::default() + }, + None, + scale, + ); + return Ok(canvas); + }; + + let mut source = + image::load_from_memory(&payload.bytes).map_err(|error| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: format!("failed to decode image `{}`: {error}", image.element_id), + })?; + if let Some((left, top, right, bottom)) = image.crop { + source = crop_dynamic_image(source, left, top, right, bottom); + } + let (left, top, width, height, cover_crop) = fit_image(image); + let target_left = ((left - image.frame.left) as f32 * scale).round() as u32; + let target_top = ((top - image.frame.top) as f32 * scale).round() as u32; + let target_width = scaled_dimension(width, scale); + let target_height = scaled_dimension(height, scale); + if let Some((crop_left, crop_top, crop_right, crop_bottom)) = cover_crop { + source = crop_dynamic_image(source, crop_left, crop_top, crop_right, crop_bottom); + } + let resized = source.resize_exact( + target_width.max(1), + target_height.max(1), + image::imageops::FilterType::Lanczos3, + ); + let rgba = resized.to_rgba8(); + blend_image(&mut canvas, &rgba, target_left, target_top); + Ok(canvas) +} + +fn render_table_element( + table: &TableElement, + document: &PresentationDocument, + scale: f32, +) -> Result { + let frame = scaled_rect(table.frame, scale); + let mut image = RgbaImage::from_pixel(frame.width.max(1), frame.height.max(1), Rgba([0, 0, 0, 0])); + let row_heights = scaled_lengths(&table.row_heights, scale); + let column_widths = scaled_lengths(&table.column_widths, scale); + let outside = table + .borders + .as_ref() + .and_then(|borders| borders.outside.as_ref()) + .map(|border| (parse_rgba(&border.color, 255), scaled_dimension(border.width, scale))) + .unwrap_or((parse_rgba(DEFAULT_TABLE_GRID_HEX, 255), 1)); + let inside = table + .borders + .as_ref() + .and_then(|borders| borders.inside.as_ref()) + .map(|border| (parse_rgba(&border.color, 255), scaled_dimension(border.width, scale))) + .unwrap_or((parse_rgba(DEFAULT_TABLE_GRID_HEX, 255), 1)); + let merge_map = build_table_merge_lookup(&table.merges); + let mut top = 0; + for (row_index, row) in table.rows.iter().enumerate() { + let row_height = row_heights + .get(row_index) + .copied() + .unwrap_or_else(|| scaled_dimension(24, scale)); + let mut left = 0; + for (column_index, cell) in row.iter().enumerate() { + let column_width = column_widths + .get(column_index) + .copied() + .unwrap_or_else(|| scaled_dimension(80, scale)); + if is_merged_away(&merge_map, row_index, column_index) { + left += column_width; + continue; + } + let (span_rows, span_columns) = + merged_span(&merge_map, row_index, column_index).unwrap_or((1, 1)); + let cell_width = column_widths + .iter() + .skip(column_index) + .take(span_columns) + .copied() + .sum::(); + let cell_height = row_heights + .iter() + .skip(row_index) + .take(span_rows) + .copied() + .sum::(); + let fill = cell + .background_fill + .as_deref() + .map(|value| parse_rgba(value, 255)) + .unwrap_or_else(|| table_cell_fill(table, row_index, column_index)); + fill_rect(&mut image, left, top, cell_width, cell_height, fill); + draw_rect_border(&mut image, left, top, cell_width, cell_height, outside.0, outside.1); + let alignment = cell.alignment.or(cell.text_style.alignment).unwrap_or(TextAlignment::Left); + draw_text_in_bounds( + &mut image, + TextBounds { + left, + top, + width: cell_width, + height: cell_height, + }, + &cell.text, + &TextStyle { + color: cell + .text_style + .color + .clone() + .or_else(|| document.theme.resolve_color("tx1")), + alignment: Some(alignment), + ..cell.text_style.clone() + }, + &cell.rich_text, + None, + scale, + ); + left += column_width; + } + top += row_height; + } + + let mut y = 0; + for height in row_heights.iter().take(row_heights.len().saturating_sub(1)) { + y += *height; + draw_horizontal_line(&mut image, y, inside.0, inside.1); + } + let mut x = 0; + for width in column_widths + .iter() + .take(column_widths.len().saturating_sub(1)) + { + x += *width; + draw_vertical_line(&mut image, x, inside.0, inside.1); + } + Ok(image) +} + +fn render_chart_element( + chart: &ChartElement, + document: &PresentationDocument, + scale: f32, +) -> Result { + let frame = scaled_rect(chart.frame, scale); + let mut image = RgbaImage::from_pixel(frame.width.max(1), frame.height.max(1), Rgba([0, 0, 0, 0])); + let image_width = image.width(); + let image_height = image.height(); + let chart_fill = chart + .chart_fill + .as_deref() + .map(|value| parse_rgba(value, 255)) + .unwrap_or(Rgba([255, 255, 255, 255])); + fill_rect(&mut image, 0, 0, image_width, image_height, chart_fill); + draw_rect_border( + &mut image, + 0, + 0, + image_width, + image_height, + parse_rgba(DEFAULT_TABLE_GRID_HEX, 255), + 1, + ); + + let title_height = scaled_dimension(28, scale).min(image.height()); + if let Some(title) = &chart.title { + draw_text_in_bounds( + &mut image, + TextBounds { + left: 8, + top: 4, + width: image_width.saturating_sub(16), + height: title_height.saturating_sub(4), + }, + title, + &TextStyle { + font_size: Some(16), + bold: true, + color: document.theme.resolve_color("tx1"), + alignment: Some(TextAlignment::Center), + ..TextStyle::default() + }, + &RichTextState::default(), + None, + scale, + ); + } + + let legend_height = if chart.has_legend && !chart.series.is_empty() { + scaled_dimension(22, scale) + } else { + 0 + }; + let plot_left = scaled_dimension(36, scale).min(image.width()); + let plot_top = title_height + scaled_dimension(8, scale); + let plot_width = image + .width() + .saturating_sub(plot_left + scaled_dimension(16, scale)); + let plot_height = image + .height() + .saturating_sub(plot_top + legend_height + scaled_dimension(12, scale)); + if plot_width == 0 || plot_height == 0 { + return Ok(image); + } + + match chart.chart_type { + ChartTypeSpec::Bar + | ChartTypeSpec::BarStacked + | ChartTypeSpec::BarStacked100 + | ChartTypeSpec::BarHorizontal => render_bar_chart( + &mut image, + chart, + plot_left, + plot_top, + plot_width, + plot_height, + scale, + ), + ChartTypeSpec::Line + | ChartTypeSpec::LineMarkers + | ChartTypeSpec::LineStacked + | ChartTypeSpec::Area + | ChartTypeSpec::AreaStacked + | ChartTypeSpec::AreaStacked100 + | ChartTypeSpec::Scatter + | ChartTypeSpec::ScatterLines + | ChartTypeSpec::ScatterSmooth + | ChartTypeSpec::Bubble => render_line_chart( + &mut image, + chart, + plot_left, + plot_top, + plot_width, + plot_height, + scale, + ), + ChartTypeSpec::Pie | ChartTypeSpec::Doughnut => { + render_pie_chart(&mut image, chart, plot_left, plot_top, plot_width, plot_height) + } + _ => { + draw_text_in_bounds( + &mut image, + TextBounds { + left: plot_left, + top: plot_top, + width: plot_width, + height: plot_height, + }, + "Preview uses a simplified chart placeholder for this chart type.", + &TextStyle { + font_size: Some(12), + color: Some(DEFAULT_TEXT_HEX.to_string()), + alignment: Some(TextAlignment::Center), + ..TextStyle::default() + }, + &RichTextState { + layout: TextLayoutState { + vertical_alignment: Some(TextVerticalAlignment::Middle), + ..TextLayoutState::default() + }, + ..RichTextState::default() + }, + None, + scale, + ); + } + } + + if legend_height > 0 { + let legend_top = image.height().saturating_sub(legend_height + 4); + render_chart_legend( + &mut image, + chart, + 8, + legend_top, + image_width.saturating_sub(16), + legend_height, + scale, + ); + } + Ok(image) +} + +fn render_bar_chart( + image: &mut RgbaImage, + chart: &ChartElement, + left: u32, + top: u32, + width: u32, + height: u32, + scale: f32, +) { + let baseline = top + height; + draw_chart_axes(image, left, top, width, height); + let series_count = chart.series.len().max(1) as u32; + let category_count = chart.categories.len().max(1) as u32; + let max_value = chart + .series + .iter() + .flat_map(|series| series.values.iter().copied()) + .fold(0.0f64, f64::max) + .max(1.0); + let group_width = width as f32 / category_count as f32; + let bar_width = (group_width / series_count as f32 * 0.72).max(2.0); + for (series_index, series) in chart.series.iter().enumerate() { + let color = chart_series_color(series, series_index); + for (value_index, value) in series.values.iter().enumerate() { + let bar_height = ((*value / max_value) as f32 * height as f32).round().max(0.0) as u32; + let x = left as f32 + + group_width * value_index as f32 + + (group_width - bar_width * series_count as f32) / 2.0 + + bar_width * series_index as f32; + let y = baseline.saturating_sub(bar_height); + fill_rect( + image, + x.round() as u32, + y, + bar_width.round().max(1.0) as u32, + bar_height.max(1), + color, + ); + } + } + render_category_labels(image, &chart.categories, left, baseline + 4, width, scale); +} + +fn render_line_chart( + image: &mut RgbaImage, + chart: &ChartElement, + left: u32, + top: u32, + width: u32, + height: u32, + scale: f32, +) { + draw_chart_axes(image, left, top, width, height); + let baseline = top + height; + let point_count = chart.categories.len().max(2); + let max_value = chart + .series + .iter() + .flat_map(|series| series.values.iter().copied()) + .fold(0.0f64, f64::max) + .max(1.0); + for (series_index, series) in chart.series.iter().enumerate() { + let color = chart_series_color(series, series_index); + let mut points = Vec::new(); + for (value_index, value) in series.values.iter().enumerate() { + let x = left as f32 + width as f32 * value_index as f32 / (point_count - 1) as f32; + let y = baseline as f32 - ((*value / max_value) as f32 * height as f32); + points.push((x, y)); + } + if points.len() >= 2 + && let Ok(path) = polyline_path(&points) + && let Ok(line) = render_vector_image(image.width(), image.height(), |pixmap| { + let mut paint = Paint::default(); + set_paint_color(&mut paint, color); + let stroke = Stroke { + width: scale.max(1.0) * 2.0, + line_cap: LineCap::Round, + line_join: LineJoin::Round, + ..Stroke::default() + }; + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + Ok(()) + }) + { + blend_image(image, &line, 0, 0); + } + for (x, y) in points { + fill_rect( + image, + x.round().max(2.0) as u32 - 2, + y.round().max(2.0) as u32 - 2, + 4, + 4, + color, + ); + } + } + render_category_labels(image, &chart.categories, left, baseline + 4, width, scale); +} + +fn render_pie_chart( + image: &mut RgbaImage, + chart: &ChartElement, + left: u32, + top: u32, + width: u32, + height: u32, +) { + let series = chart.series.first(); + let Some(series) = series else { + return; + }; + let radius = (width.min(height) as f32 / 2.0).max(2.0); + let center = (left as f32 + width as f32 / 2.0, top as f32 + height as f32 / 2.0); + let total = series + .values + .iter() + .map(|value| value.abs()) + .sum::() + .max(1.0); + let mut start_angle = -PI / 2.0; + for (index, value) in series.values.iter().enumerate() { + let sweep = (*value / total) as f32 * PI * 2.0; + if sweep <= 0.0 { + continue; + } + let end_angle = start_angle + sweep; + if let Ok(path) = sector_path(center, radius, start_angle, end_angle, chart.chart_type == ChartTypeSpec::Doughnut) + && let Ok(sector) = render_vector_image(image.width(), image.height(), |pixmap| { + let mut paint = Paint::default(); + set_paint_color(&mut paint, chart_palette_color(index)); + pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + Ok(()) + }) + { + blend_image(image, §or, 0, 0); + } + start_angle = end_angle; + } +} + +fn render_chart_legend( + image: &mut RgbaImage, + chart: &ChartElement, + left: u32, + top: u32, + width: u32, + height: u32, + scale: f32, +) { + let mut cursor = left; + let swatch = scaled_dimension(10, scale).max(6); + for (index, series) in chart.series.iter().enumerate() { + if cursor >= left + width { + break; + } + fill_rect(image, cursor, top + (height.saturating_sub(swatch)) / 2, swatch, swatch, chart_series_color(series, index)); + cursor += swatch + scaled_dimension(4, scale); + let label_width = scaled_dimension((series.name.chars().count() as u32).saturating_mul(7), scale).max(40); + draw_text_in_bounds( + image, + TextBounds { + left: cursor, + top, + width: label_width.min(left + width - cursor), + height, + }, + &series.name, + &TextStyle { + font_size: Some(12), + color: Some(DEFAULT_TEXT_HEX.to_string()), + ..TextStyle::default() + }, + &RichTextState { + layout: TextLayoutState { + vertical_alignment: Some(TextVerticalAlignment::Middle), + ..TextLayoutState::default() + }, + ..RichTextState::default() + }, + None, + scale, + ); + cursor += label_width + scaled_dimension(10, scale); + } +} + +fn render_comment_overlays( + canvas: &mut RgbaImage, + document: &PresentationDocument, + slide: &PresentationSlide, + scale: f32, +) { + let radius = scaled_dimension(8, scale).max(6); + for thread in &document.comment_threads { + let matches_slide = match &thread.target { + CommentTarget::Slide { slide_id } + | CommentTarget::Element { slide_id, .. } + | CommentTarget::TextRange { slide_id, .. } => slide_id == &slide.slide_id, + }; + if !matches_slide { + continue; + } + let Some(position) = thread.position.as_ref() else { + continue; + }; + let x = scaled_dimension(position.x, scale); + let y = scaled_dimension(position.y, scale); + if let Ok(marker) = render_vector_image(radius * 2, radius * 2, |pixmap| { + let path = ellipse_path(pixmap.width(), pixmap.height())?; + let mut paint = Paint::default(); + set_paint_color(&mut paint, parse_rgba(DEFAULT_COMMENT_HEX, 230)); + pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + Ok(()) + }) { + blend_image(canvas, &marker, x.saturating_sub(radius), y.saturating_sub(radius)); + } + } +} + +fn render_vector_image( + width: u32, + height: u32, + draw: impl FnOnce(&mut Pixmap) -> Result<(), PresentationArtifactError>, +) -> Result { + let mut pixmap = Pixmap::new(width.max(1), height.max(1)).ok_or_else(|| { + PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: format!("failed to allocate pixmap {width}x{height}"), + } + })?; + draw(&mut pixmap)?; + RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().to_vec()).ok_or_else( + || PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "failed to convert vector preview".to_string(), + }, + ) +} + +fn draw_text_box( + image: &mut RgbaImage, + text: &str, + style: &TextStyle, + rich_text: &RichTextState, + hyperlink: Option<&HyperlinkState>, + scale: f32, +) { + draw_text_in_bounds( + image, + TextBounds { + left: 0, + top: 0, + width: image.width(), + height: image.height(), + }, + text, + style, + rich_text, + hyperlink, + scale, + ); +} + +fn draw_text_in_bounds( + image: &mut RgbaImage, + bounds: TextBounds, + text: &str, + style: &TextStyle, + rich_text: &RichTextState, + hyperlink: Option<&HyperlinkState>, + scale: f32, +) { + if text.is_empty() || bounds.width == 0 || bounds.height == 0 { + return; + } + let insets = rich_text.layout.insets.unwrap_or(TextInsets { + left: DEFAULT_TEXT_INSET, + right: DEFAULT_TEXT_INSET, + top: DEFAULT_TEXT_INSET, + bottom: DEFAULT_TEXT_INSET, + }); + let content_left = bounds.left.saturating_add(scaled_dimension(insets.left, scale)); + let content_top = bounds.top.saturating_add(scaled_dimension(insets.top, scale)); + let content_width = bounds.width + .saturating_sub(scaled_dimension(insets.left.saturating_add(insets.right), scale)); + let content_height = bounds.height + .saturating_sub(scaled_dimension(insets.top.saturating_add(insets.bottom), scale)); + if content_width == 0 || content_height == 0 { + return; + } + + let glyphs = styled_glyphs(text, style, rich_text, hyperlink); + let alignment = style.alignment.unwrap_or(TextAlignment::Left); + let wrap = rich_text.layout.wrap.unwrap_or(TextWrapMode::Square); + let vertical_alignment = rich_text + .layout + .vertical_alignment + .unwrap_or(TextVerticalAlignment::Top); + let mut font_px = scaled_dimension(style.font_size.unwrap_or(14), scale).max(MIN_TEXT_PIXELS); + let auto_fit = rich_text.layout.auto_fit.unwrap_or(TextAutoFitMode::None); + let config = loop { + let line_height = ((font_px as f32) * 1.35).round().max(font_px as f32) as u32; + let config = TextRenderConfig { + width: content_width, + font_px, + line_height, + wrap, + vertical_alignment, + alignment, + }; + let lines = layout_text_lines(&glyphs, config); + let used_height = (lines.len() as u32).saturating_mul(line_height); + let fits_height = used_height <= content_height; + let fits_width = lines.iter().all(|line| line.width <= content_width); + if auto_fit != TextAutoFitMode::ShrinkText + || (fits_height && fits_width) + || font_px <= MIN_TEXT_PIXELS + { + break (config, lines); + } + font_px = font_px.saturating_sub(1); + }; + + let (config, lines) = config; + let used_height = (lines.len() as u32).saturating_mul(config.line_height); + let mut y = match config.vertical_alignment { + TextVerticalAlignment::Top => content_top, + TextVerticalAlignment::Middle => { + content_top + content_height.saturating_sub(used_height) / 2 + } + TextVerticalAlignment::Bottom => content_top + content_height.saturating_sub(used_height), + }; + + for (line_index, line) in lines.iter().enumerate() { + let extra_space = content_width.saturating_sub(line.width); + let start_x = match config.alignment { + TextAlignment::Left | TextAlignment::Justify => content_left, + TextAlignment::Center => content_left + extra_space / 2, + TextAlignment::Right => content_left + extra_space, + }; + let justify_gap = if config.alignment == TextAlignment::Justify + && line_index + 1 < lines.len() + && line.spaces > 0 + { + extra_space / line.spaces as u32 + } else { + 0 + }; + + let mut x = start_x; + for glyph in &line.glyphs { + let glyph_width = draw_bitmap_glyph(image, x, y, glyph, config.font_px); + x = x.saturating_add(glyph_width); + if glyph.ch == ' ' && justify_gap > 0 { + x = x.saturating_add(justify_gap); + } + } + y = y.saturating_add(config.line_height); + if y >= content_top + content_height { + break; + } + } +} + +fn styled_glyphs( + text: &str, + base_style: &TextStyle, + rich_text: &RichTextState, + hyperlink: Option<&HyperlinkState>, +) -> Vec { + let chars = text.chars().collect::>(); + let mut glyphs = chars + .iter() + .map(|ch| StyledGlyph { + ch: *ch, + style: base_style.clone(), + is_link: hyperlink.is_some(), + }) + .collect::>(); + for range in &rich_text.ranges { + let glyph_count = glyphs.len(); + let start = range.start_cp.min(glyph_count); + let end = range.start_cp.saturating_add(range.length).min(glyphs.len()); + for glyph in glyphs + .iter_mut() + .skip(start) + .take(end.saturating_sub(start)) + { + glyph.style = merged_text_style(&glyph.style, &range.style); + if range.hyperlink.is_some() { + glyph.is_link = true; + } + } + } + if hyperlink.is_some() { + for glyph in &mut glyphs { + glyph.is_link = true; + } + } + glyphs +} + +fn merged_text_style(base: &TextStyle, overlay: &TextStyle) -> TextStyle { + TextStyle { + style_name: overlay.style_name.clone().or(base.style_name.clone()), + font_size: overlay.font_size.or(base.font_size), + font_family: overlay.font_family.clone().or(base.font_family.clone()), + color: overlay.color.clone().or(base.color.clone()), + alignment: overlay.alignment.or(base.alignment), + bold: overlay.bold || base.bold, + italic: overlay.italic || base.italic, + underline: overlay.underline || base.underline, + } +} + +fn layout_text_lines(glyphs: &[StyledGlyph], config: TextRenderConfig) -> Vec { + let mut lines = Vec::new(); + let mut current = Vec::new(); + let mut current_width = 0; + let mut current_spaces = 0; + for glyph in glyphs { + if glyph.ch == '\n' { + lines.push(TextLine { + glyphs: std::mem::take(&mut current), + width: current_width, + spaces: current_spaces, + }); + current_width = 0; + current_spaces = 0; + continue; + } + let glyph_width = measure_glyph_width(glyph, config.font_px); + if config.wrap == TextWrapMode::Square + && !current.is_empty() + && current_width.saturating_add(glyph_width) > config.width + { + lines.push(TextLine { + glyphs: std::mem::take(&mut current), + width: current_width, + spaces: current_spaces, + }); + current_width = 0; + current_spaces = 0; + } + if glyph.ch == ' ' { + current_spaces += 1; + } + current_width = current_width.saturating_add(glyph_width); + current.push(glyph.clone()); + } + if !current.is_empty() || lines.is_empty() { + lines.push(TextLine { + glyphs: current, + width: current_width, + spaces: current_spaces, + }); + } + lines +} + +fn draw_bitmap_glyph( + image: &mut RgbaImage, + left: u32, + top: u32, + glyph: &StyledGlyph, + font_px: u32, +) -> u32 { + let scale = (font_px / 8).max(1); + if glyph.ch == ' ' { + return 4 * scale; + } + let rows = BASIC_FONTS + .get(glyph.ch) + .or_else(|| BASIC_FONTS.get('?')) + .unwrap_or([0; 8]); + let color = glyph_color(&glyph.style, glyph.is_link); + let italic_shift = if glyph.style.italic { scale / 2 } else { 0 }; + for (row_index, row) in rows.iter().enumerate() { + for column in 0..8u32 { + if row & (1 << column) == 0 { + continue; + } + let draw_x = left + .saturating_add((7 - column) * scale) + .saturating_add(((7 - row_index as u32) * italic_shift) / 6); + let draw_y = top.saturating_add(row_index as u32 * scale); + fill_rect(image, draw_x, draw_y, scale, scale, color); + if glyph.style.bold { + fill_rect(image, draw_x.saturating_add(1), draw_y, scale, scale, color); + } + } + } + let width = measure_glyph_width(glyph, font_px); + if glyph.style.underline || glyph.is_link { + let underline_y = top.saturating_add(8 * scale).saturating_sub(scale / 2 + 1); + fill_rect(image, left, underline_y, width.saturating_sub(scale / 2), scale.max(1), color); + } + width +} + +fn measure_glyph_width(glyph: &StyledGlyph, font_px: u32) -> u32 { + let scale = (font_px / 8).max(1); + match glyph.ch { + ' ' => 4 * scale, + '\t' => 8 * scale, + _ => 8 * scale, + } +} + +fn glyph_color(style: &TextStyle, is_link: bool) -> Rgba { + if let Some(color) = &style.color { + return parse_rgba(color, 255); + } + if is_link || style.underline { + parse_rgba(DEFAULT_LINK_HEX, 255) + } else { + parse_rgba(DEFAULT_TEXT_HEX, 255) + } +} + +fn slide_background_rgba(slide: &PresentationSlide, document: &PresentationDocument) -> Rgba { + slide + .background_fill + .as_deref() + .map(|fill| parse_rgba(fill, 255)) + .or_else(|| { + document + .theme + .resolve_color("bg1") + .map(|fill| parse_rgba(&fill, 255)) + }) + .unwrap_or_else(|| parse_rgba(DEFAULT_BACKGROUND_HEX, 255)) +} + +fn scaled_rect(rect: Rect, scale: f32) -> Rect { + Rect { + left: scaled_dimension(rect.left, scale), + top: scaled_dimension(rect.top, scale), + width: scaled_dimension(rect.width, scale), + height: scaled_dimension(rect.height, scale), + } +} + +fn scaled_dimension(value: u32, scale: f32) -> u32 { + ((value as f32) * scale).round().max(1.0) as u32 +} + +fn scaled_lengths(values: &[u32], scale: f32) -> Vec { + values + .iter() + .copied() + .map(|value| scaled_dimension(value, scale)) + .collect() +} + +fn scaled_connector_bounds(connector: &ConnectorElement, scale: f32) -> (u32, u32, u32, u32) { + let min_left = connector.start.left.min(connector.end.left); + let min_top = connector.start.top.min(connector.end.top); + let max_left = connector.start.left.max(connector.end.left); + let max_top = connector.start.top.max(connector.end.top); + let padding = (connector.line.width.max(8) * 2) as f32 * scale; + let left = ((min_left as f32) * scale - padding).max(0.0).round() as u32; + let top = ((min_top as f32) * scale - padding).max(0.0).round() as u32; + let right = ((max_left as f32) * scale + padding).round() as u32; + let bottom = ((max_top as f32) * scale + padding).round() as u32; + (left, top, right.saturating_sub(left), bottom.saturating_sub(top)) +} + +fn fill_rect( + image: &mut RgbaImage, + left: u32, + top: u32, + width: u32, + height: u32, + color: Rgba, +) { + let max_x = left.saturating_add(width).min(image.width()); + let max_y = top.saturating_add(height).min(image.height()); + for y in top.min(image.height())..max_y { + for x in left.min(image.width())..max_x { + blend_pixel(image.get_pixel_mut(x, y), color); + } + } +} + +fn blend_image(canvas: &mut RgbaImage, overlay: &RgbaImage, left: u32, top: u32) { + for (x, y, pixel) in overlay.enumerate_pixels() { + if pixel.0[3] == 0 { + continue; + } + let dest_x = left.saturating_add(x); + let dest_y = top.saturating_add(y); + if dest_x >= canvas.width() || dest_y >= canvas.height() { + continue; + } + blend_pixel(canvas.get_pixel_mut(dest_x, dest_y), *pixel); + } +} + +fn blend_transformed_image( + canvas: &mut RgbaImage, + overlay: &RgbaImage, + left: u32, + top: u32, + rotation_degrees: f32, + flip_horizontal: bool, + flip_vertical: bool, +) { + if rotation_degrees.rem_euclid(360.0) == 0.0 && !flip_horizontal && !flip_vertical { + blend_image(canvas, overlay, left, top); + return; + } + let width = overlay.width() as f32; + let height = overlay.height() as f32; + let center = (left as f32 + width / 2.0, top as f32 + height / 2.0); + let radians = rotation_degrees * PI / 180.0; + let sin = radians.sin(); + let cos = radians.cos(); + let corners = [ + (-width / 2.0, -height / 2.0), + (width / 2.0, -height / 2.0), + (-width / 2.0, height / 2.0), + (width / 2.0, height / 2.0), + ]; + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for (x, y) in corners { + let rx = x * cos - y * sin; + let ry = x * sin + y * cos; + min_x = min_x.min(center.0 + rx); + min_y = min_y.min(center.1 + ry); + max_x = max_x.max(center.0 + rx); + max_y = max_y.max(center.1 + ry); + } + let start_x = min_x.floor().max(0.0) as u32; + let start_y = min_y.floor().max(0.0) as u32; + let end_x = max_x.ceil().min(canvas.width() as f32) as u32; + let end_y = max_y.ceil().min(canvas.height() as f32) as u32; + for dest_y in start_y..end_y { + for dest_x in start_x..end_x { + let mut local_x = dest_x as f32 + 0.5 - center.0; + let mut local_y = dest_y as f32 + 0.5 - center.1; + let src_x = local_x * cos + local_y * sin; + let src_y = -local_x * sin + local_y * cos; + local_x = if flip_horizontal { -src_x } else { src_x }; + local_y = if flip_vertical { -src_y } else { src_y }; + let sample_x = local_x + width / 2.0 - 0.5; + let sample_y = local_y + height / 2.0 - 0.5; + if sample_x < 0.0 || sample_y < 0.0 || sample_x >= width || sample_y >= height { + continue; + } + let pixel = overlay.get_pixel(sample_x as u32, sample_y as u32); + if pixel.0[3] == 0 { + continue; + } + blend_pixel(canvas.get_pixel_mut(dest_x, dest_y), *pixel); + } + } +} + +fn blend_pixel(target: &mut Rgba, source: Rgba) { + let src_alpha = source.0[3] as f32 / 255.0; + if src_alpha <= 0.0 { + return; + } + let dst_alpha = target.0[3] as f32 / 255.0; + let out_alpha = src_alpha + dst_alpha * (1.0 - src_alpha); + for channel in 0..3 { + let src = source.0[channel] as f32 / 255.0; + let dst = target.0[channel] as f32 / 255.0; + let out = (src * src_alpha + dst * dst_alpha * (1.0 - src_alpha)) / out_alpha.max(1e-6); + target.0[channel] = (out * 255.0).round().clamp(0.0, 255.0) as u8; + } + target.0[3] = (out_alpha * 255.0).round().clamp(0.0, 255.0) as u8; +} + +fn set_paint_color(paint: &mut Paint<'_>, color: Rgba) { + paint.set_color_rgba8(color.0[0], color.0[1], color.0[2], color.0[3]); +} + +fn parse_rgba(hex: &str, alpha: u8) -> Rgba { + let value = hex.trim().trim_start_matches('#'); + if value.len() == 6 + && let Ok(rgb) = u32::from_str_radix(value, 16) + { + return Rgba([ + ((rgb >> 16) & 0xFF) as u8, + ((rgb >> 8) & 0xFF) as u8, + (rgb & 0xFF) as u8, + alpha, + ]); + } + Rgba([0, 0, 0, alpha]) +} + +fn shape_path( + geometry: ShapeGeometry, + width: u32, + height: u32, +) -> Result { + let width = width.max(1) as f32; + let height = height.max(1) as f32; + match geometry { + ShapeGeometry::Rectangle | ShapeGeometry::RoundedRectangle => { + polygon_path(&[(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]) + } + ShapeGeometry::Ellipse => ellipse_path(width as u32, height as u32), + ShapeGeometry::Triangle => polygon_path(&[(width / 2.0, 0.0), (width, height), (0.0, height)]), + ShapeGeometry::RightTriangle => polygon_path(&[(0.0, 0.0), (width, height), (0.0, height)]), + ShapeGeometry::Diamond => polygon_path(&[ + (width / 2.0, 0.0), + (width, height / 2.0), + (width / 2.0, height), + (0.0, height / 2.0), + ]), + ShapeGeometry::Pentagon => regular_polygon_path(5, width, height, -PI / 2.0), + ShapeGeometry::Hexagon => regular_polygon_path(6, width, height, 0.0), + ShapeGeometry::Octagon => regular_polygon_path(8, width, height, PI / 8.0), + ShapeGeometry::Star4 => star_path(4, width, height), + ShapeGeometry::Star5 => star_path(5, width, height), + ShapeGeometry::Star6 => star_path(6, width, height), + ShapeGeometry::Star8 => star_path(8, width, height), + ShapeGeometry::RightArrow => polygon_path(&[ + (0.0, height * 0.25), + (width * 0.55, height * 0.25), + (width * 0.55, 0.0), + (width, height * 0.5), + (width * 0.55, height), + (width * 0.55, height * 0.75), + (0.0, height * 0.75), + ]), + ShapeGeometry::LeftArrow => polygon_path(&[ + (width, height * 0.25), + (width * 0.45, height * 0.25), + (width * 0.45, 0.0), + (0.0, height * 0.5), + (width * 0.45, height), + (width * 0.45, height * 0.75), + (width, height * 0.75), + ]), + ShapeGeometry::UpArrow => polygon_path(&[ + (width * 0.25, height), + (width * 0.25, height * 0.45), + (0.0, height * 0.45), + (width * 0.5, 0.0), + (width, height * 0.45), + (width * 0.75, height * 0.45), + (width * 0.75, height), + ]), + ShapeGeometry::DownArrow => polygon_path(&[ + (width * 0.25, 0.0), + (width * 0.25, height * 0.55), + (0.0, height * 0.55), + (width * 0.5, height), + (width, height * 0.55), + (width * 0.75, height * 0.55), + (width * 0.75, 0.0), + ]), + ShapeGeometry::LeftRightArrow => polygon_path(&[ + (0.0, height * 0.5), + (width * 0.18, 0.0), + (width * 0.18, height * 0.25), + (width * 0.82, height * 0.25), + (width * 0.82, 0.0), + (width, height * 0.5), + (width * 0.82, height), + (width * 0.82, height * 0.75), + (width * 0.18, height * 0.75), + (width * 0.18, height), + ]), + ShapeGeometry::UpDownArrow => polygon_path(&[ + (width * 0.5, 0.0), + (0.0, height * 0.18), + (width * 0.25, height * 0.18), + (width * 0.25, height * 0.82), + (0.0, height * 0.82), + (width * 0.5, height), + (width, height * 0.82), + (width * 0.75, height * 0.82), + (width * 0.75, height * 0.18), + (width, height * 0.18), + ]), + ShapeGeometry::Chevron => polygon_path(&[ + (0.0, 0.0), + (width * 0.58, 0.0), + (width, height * 0.5), + (width * 0.58, height), + (0.0, height), + (width * 0.42, height * 0.5), + ]), + ShapeGeometry::Heart => heart_path(width, height), + ShapeGeometry::Cloud => cloud_path(width, height), + ShapeGeometry::Wave => wave_path(width, height), + ShapeGeometry::FlowChartProcess + | ShapeGeometry::FlowChartDecision + | ShapeGeometry::FlowChartConnector + | ShapeGeometry::Parallelogram + | ShapeGeometry::Trapezoid => polygon_path(&[ + (width * 0.12, 0.0), + (width, 0.0), + (width * 0.88, height), + (0.0, height), + ]), + } +} + +fn polygon_path(points: &[(f32, f32)]) -> Result { + let mut builder = PathBuilder::new(); + let Some((first_x, first_y)) = points.first().copied() else { + return Err(PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "polygon path had no points".to_string(), + }); + }; + builder.move_to(first_x, first_y); + for (x, y) in points.iter().copied().skip(1) { + builder.line_to(x, y); + } + builder.close(); + builder.finish().ok_or_else(|| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "failed to build polygon path".to_string(), + }) +} + +fn polyline_path(points: &[(f32, f32)]) -> Result { + let mut builder = PathBuilder::new(); + let Some((first_x, first_y)) = points.first().copied() else { + return Err(PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "polyline path had no points".to_string(), + }); + }; + builder.move_to(first_x, first_y); + for (x, y) in points.iter().copied().skip(1) { + builder.line_to(x, y); + } + builder.finish().ok_or_else(|| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "failed to build polyline path".to_string(), + }) +} + +fn ellipse_path(width: u32, height: u32) -> Result { + let cx = width as f32 / 2.0; + let cy = height as f32 / 2.0; + let rx = width as f32 / 2.0; + let ry = height as f32 / 2.0; + let points = (0..32) + .map(|index| { + let angle = index as f32 / 32.0 * PI * 2.0; + (cx + rx * angle.cos(), cy + ry * angle.sin()) + }) + .collect::>(); + polygon_path(&points) +} + +fn regular_polygon_path( + sides: usize, + width: f32, + height: f32, + rotation: f32, +) -> Result { + let cx = width / 2.0; + let cy = height / 2.0; + let radius = width.min(height) / 2.0; + let points = (0..sides) + .map(|index| { + let angle = rotation + index as f32 * PI * 2.0 / sides as f32; + (cx + radius * angle.cos(), cy + radius * angle.sin()) + }) + .collect::>(); + polygon_path(&points) +} + +fn star_path(points: usize, width: f32, height: f32) -> Result { + let cx = width / 2.0; + let cy = height / 2.0; + let outer = width.min(height) / 2.0; + let inner = outer * 0.45; + let mut vertices = Vec::with_capacity(points * 2); + for index in 0..points * 2 { + let angle = -PI / 2.0 + index as f32 * PI / points as f32; + let radius = if index % 2 == 0 { outer } else { inner }; + vertices.push((cx + radius * angle.cos(), cy + radius * angle.sin())); + } + polygon_path(&vertices) +} + +fn heart_path(width: f32, height: f32) -> Result { + let mut builder = PathBuilder::new(); + builder.move_to(width / 2.0, height); + builder.cubic_to(width * 0.1, height * 0.7, -width * 0.05, height * 0.35, width * 0.25, height * 0.2); + builder.cubic_to(width * 0.45, 0.0, width * 0.5, height * 0.15, width / 2.0, height * 0.25); + builder.cubic_to(width * 0.5, height * 0.15, width * 0.55, 0.0, width * 0.75, height * 0.2); + builder.cubic_to(width * 1.05, height * 0.35, width * 0.9, height * 0.7, width / 2.0, height); + builder.close(); + builder.finish().ok_or_else(|| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "failed to build heart path".to_string(), + }) +} + +fn cloud_path(width: f32, height: f32) -> Result { + let mut builder = PathBuilder::new(); + builder.move_to(width * 0.2, height * 0.75); + builder.cubic_to(0.0, height * 0.75, 0.0, height * 0.35, width * 0.22, height * 0.35); + builder.cubic_to(width * 0.22, height * 0.1, width * 0.52, height * 0.05, width * 0.6, height * 0.3); + builder.cubic_to(width * 0.85, height * 0.18, width, height * 0.45, width * 0.85, height * 0.68); + builder.cubic_to(width * 0.96, height * 0.95, width * 0.64, height, width * 0.55, height * 0.82); + builder.cubic_to(width * 0.42, height, width * 0.22, height * 0.96, width * 0.2, height * 0.75); + builder.close(); + builder.finish().ok_or_else(|| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "failed to build cloud path".to_string(), + }) +} + +fn wave_path(width: f32, height: f32) -> Result { + let mut builder = PathBuilder::new(); + builder.move_to(0.0, height * 0.55); + builder.cubic_to(width * 0.18, 0.0, width * 0.32, height, width * 0.5, height * 0.55); + builder.cubic_to(width * 0.68, 0.1, width * 0.82, height, width, height * 0.45); + builder.line_to(width, height); + builder.line_to(0.0, height); + builder.close(); + builder.finish().ok_or_else(|| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "failed to build wave path".to_string(), + }) +} + +fn connector_path( + kind: ConnectorKind, + start: (f32, f32), + end: (f32, f32), +) -> Result { + match kind { + ConnectorKind::Straight => polyline_path(&[start, end]), + ConnectorKind::Elbow => polyline_path(&[start, (start.0, end.1), end]), + ConnectorKind::Curved => { + let mut builder = PathBuilder::new(); + builder.move_to(start.0, start.1); + let mid_x = (start.0 + end.0) / 2.0; + builder.cubic_to(mid_x, start.1, mid_x, end.1, end.0, end.1); + builder.finish().ok_or_else(|| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "failed to build connector path".to_string(), + }) + } + } +} + +fn draw_arrow_head( + pixmap: &mut Pixmap, + tip: (f32, f32), + tail: (f32, f32), + kind: ConnectorArrowKind, + size: f32, + color: &str, +) -> Result<(), PresentationArtifactError> { + if kind == ConnectorArrowKind::None || size <= 0.0 { + return Ok(()); + } + let dx = tip.0 - tail.0; + let dy = tip.1 - tail.1; + let length = (dx * dx + dy * dy).sqrt().max(1.0); + let ux = dx / length; + let uy = dy / length; + let px = -uy; + let py = ux; + let points = match kind { + ConnectorArrowKind::Triangle | ConnectorArrowKind::Stealth | ConnectorArrowKind::Open => vec![ + tip, + (tip.0 - ux * size + px * size * 0.45, tip.1 - uy * size + py * size * 0.45), + (tip.0 - ux * size - px * size * 0.45, tip.1 - uy * size - py * size * 0.45), + ], + ConnectorArrowKind::Diamond => vec![ + tip, + (tip.0 - ux * size * 0.5 + px * size * 0.35, tip.1 - uy * size * 0.5 + py * size * 0.35), + (tip.0 - ux * size, tip.1 - uy * size), + (tip.0 - ux * size * 0.5 - px * size * 0.35, tip.1 - uy * size * 0.5 - py * size * 0.35), + ], + ConnectorArrowKind::Oval => { + let path = ellipse_path(size.max(2.0) as u32, size.max(2.0) as u32)?; + let marker = render_vector_image(size.max(2.0) as u32, size.max(2.0) as u32, |marker| { + let mut paint = Paint::default(); + set_paint_color(&mut paint, parse_rgba(color, 255)); + marker.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + Ok(()) + })?; + let overlay_left = tip.0.round().max(0.0) as u32; + let overlay_top = tip.1.round().max(0.0) as u32; + let mut image = RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixmap.data().to_vec()) + .ok_or_else(|| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "failed to build arrow image".to_string(), + })?; + blend_image( + &mut image, + &marker, + overlay_left.saturating_sub(marker.width() / 2), + overlay_top.saturating_sub(marker.height() / 2), + ); + let data = image.into_raw(); + pixmap.data_mut().copy_from_slice(&data); + return Ok(()); + } + ConnectorArrowKind::None => return Ok(()), + }; + let path = polygon_path(&points)?; + let mut paint = Paint::default(); + set_paint_color(&mut paint, parse_rgba(color, 255)); + if kind == ConnectorArrowKind::Open { + let stroke = Stroke { + width: 1.5, + ..Stroke::default() + }; + pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } else { + pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + } + Ok(()) +} + +fn crop_dynamic_image( + image: DynamicImage, + left: f64, + top: f64, + right: f64, + bottom: f64, +) -> DynamicImage { + let width = image.width(); + let height = image.height(); + let crop_left = (width as f64 * left).round().clamp(0.0, width as f64) as u32; + let crop_top = (height as f64 * top).round().clamp(0.0, height as f64) as u32; + let crop_right = (width as f64 * right).round().clamp(0.0, width as f64) as u32; + let crop_bottom = (height as f64 * bottom).round().clamp(0.0, height as f64) as u32; + let cropped_width = width.saturating_sub(crop_left.saturating_add(crop_right)).max(1); + let cropped_height = height.saturating_sub(crop_top.saturating_add(crop_bottom)).max(1); + image.crop_imm(crop_left, crop_top, cropped_width, cropped_height) +} + +fn build_table_merge_lookup(merges: &[TableMergeRegion]) -> Vec { + merges.to_vec() +} + +fn merged_span( + merges: &[TableMergeRegion], + row: usize, + column: usize, +) -> Option<(usize, usize)> { + merges.iter().find_map(|merge| { + (merge.start_row == row && merge.start_column == column).then_some(( + merge.end_row.saturating_sub(merge.start_row) + 1, + merge.end_column.saturating_sub(merge.start_column) + 1, + )) + }) +} + +fn is_merged_away(merges: &[TableMergeRegion], row: usize, column: usize) -> bool { + merges.iter().any(|merge| { + row >= merge.start_row + && row <= merge.end_row + && column >= merge.start_column + && column <= merge.end_column + && !(merge.start_row == row && merge.start_column == column) + }) +} + +fn table_cell_fill(table: &TableElement, row: usize, column: usize) -> Rgba { + if row == 0 && table.style_options.header_row { + return parse_rgba("EAF0F8", 255); + } + if table.style_options.banded_rows && row % 2 == 1 { + return parse_rgba("F7F9FB", 255); + } + if table.style_options.banded_columns && column % 2 == 1 { + return parse_rgba("F7F9FB", 255); + } + Rgba([255, 255, 255, 255]) +} + +fn draw_rect_border( + image: &mut RgbaImage, + left: u32, + top: u32, + width: u32, + height: u32, + color: Rgba, + thickness: u32, +) { + fill_rect(image, left, top, width, thickness, color); + fill_rect( + image, + left, + top.saturating_add(height.saturating_sub(thickness)), + width, + thickness, + color, + ); + fill_rect(image, left, top, thickness, height, color); + fill_rect( + image, + left.saturating_add(width.saturating_sub(thickness)), + top, + thickness, + height, + color, + ); +} + +fn draw_horizontal_line(image: &mut RgbaImage, y: u32, color: Rgba, thickness: u32) { + fill_rect(image, 0, y.saturating_sub(thickness / 2), image.width(), thickness.max(1), color); +} + +fn draw_vertical_line(image: &mut RgbaImage, x: u32, color: Rgba, thickness: u32) { + fill_rect(image, x.saturating_sub(thickness / 2), 0, thickness.max(1), image.height(), color); +} + +fn draw_chart_axes(image: &mut RgbaImage, left: u32, top: u32, width: u32, height: u32) { + let axis_color = parse_rgba(DEFAULT_TABLE_GRID_HEX, 255); + fill_rect(image, left, top, 1, height, axis_color); + fill_rect(image, left, top + height, width, 1, axis_color); +} + +fn render_category_labels( + image: &mut RgbaImage, + categories: &[String], + left: u32, + top: u32, + width: u32, + scale: f32, +) { + if categories.is_empty() { + return; + } + let step = width as f32 / categories.len().max(1) as f32; + for (index, category) in categories.iter().enumerate() { + let x = left + (step * index as f32) as u32; + draw_text_in_bounds( + image, + TextBounds { + left: x, + top, + width: step.round().max(1.0) as u32, + height: scaled_dimension(20, scale), + }, + category, + &TextStyle { + font_size: Some(10), + color: Some(DEFAULT_TEXT_HEX.to_string()), + alignment: Some(TextAlignment::Center), + ..TextStyle::default() + }, + &RichTextState::default(), + None, + scale, + ); + } +} + +fn chart_series_color(series: &ChartSeriesSpec, index: usize) -> Rgba { + series + .fill + .as_deref() + .map(|fill| parse_rgba(fill, 255)) + .unwrap_or_else(|| chart_palette_color(index)) +} + +fn chart_palette_color(index: usize) -> Rgba { + let palette = ["5B8FF9", "5AD8A6", "5D7092", "F6BD16", "E8684A", "6DC8EC", "9270CA"]; + parse_rgba(palette[index % palette.len()], 255) +} + +fn sector_path( + center: (f32, f32), + radius: f32, + start: f32, + end: f32, + doughnut: bool, +) -> Result { + let steps = (((end - start).abs() / (PI / 18.0)).ceil() as usize).max(2); + let mut points = Vec::new(); + if !doughnut { + points.push(center); + } + for index in 0..=steps { + let angle = start + (end - start) * index as f32 / steps as f32; + points.push((center.0 + radius * angle.cos(), center.1 + radius * angle.sin())); + } + if doughnut { + let inner = radius * 0.55; + for index in (0..=steps).rev() { + let angle = start + (end - start) * index as f32 / steps as f32; + points.push((center.0 + inner * angle.cos(), center.1 + inner * angle.sin())); + } + } + polygon_path(&points) +} + +fn resolve_render_slide_index( + document: &PresentationDocument, + slide_index: Option, + action: &str, +) -> Result { + if let Some(slide_index) = slide_index { + let slide_index = usize::try_from(slide_index).map_err(|_| { + PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "`slide_index` does not fit in usize".to_string(), + } + })?; + if slide_index >= document.slides.len() { + return Err(index_out_of_range(action, slide_index, document.slides.len())); + } + return Ok(slide_index); + } + if let Some(active_slide_index) = document.active_slide_index { + return Ok(active_slide_index); + } + if document.slides.is_empty() { + return Err(PresentationArtifactError::InvalidArgs { + action: action.to_string(), + message: "presentation has no slides".to_string(), + }); + } + Ok(0) +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/response.rs b/codex-rs/artifact-presentation/src/presentation_artifact/response.rs index 241cce570d..87ab5a9ed3 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/response.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/response.rs @@ -27,6 +27,14 @@ pub struct PresentationArtifactResponse { pub patch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub active_slide_index: Option, + #[serde(skip)] + pub rendered_preview: Option, +} + +#[derive(Debug, Clone)] +pub struct RenderedPreview { + pub slide_index: usize, + pub png_bytes: Vec, } impl PresentationArtifactResponse { @@ -52,6 +60,7 @@ impl PresentationArtifactResponse { proto_json: None, patch: None, active_slide_index: None, + rendered_preview: None, } } } @@ -78,6 +87,7 @@ fn response_for_document_state( proto_json: None, patch: None, active_slide_index: document.and_then(|current| current.active_slide_index), + rendered_preview: None, } } diff --git a/codex-rs/artifact-presentation/src/tests.rs b/codex-rs/artifact-presentation/src/tests.rs index 2ced6ad642..9cd00e19b6 100644 --- a/codex-rs/artifact-presentation/src/tests.rs +++ b/codex-rs/artifact-presentation/src/tests.rs @@ -1,5 +1,6 @@ use super::presentation_artifact::*; use base64::Engine; +use image::GenericImageView; use pretty_assertions::assert_eq; use std::io::Read; @@ -29,6 +30,28 @@ fn parse_ndjson_lines(ndjson: &str) -> Result, serde_json .collect() } +fn load_preview(bytes: &[u8]) -> Result> { + Ok(image::load_from_memory(bytes)?) +} + +fn rect_contains_pixel( + image: &image::DynamicImage, + left: u32, + top: u32, + width: u32, + height: u32, + expected: [u8; 4], +) -> bool { + for y in top..top.saturating_add(height) { + for x in left..left.saturating_add(width) { + if image.get_pixel(x, y).0 == expected { + return true; + } + } + } + false +} + #[test] fn manager_can_create_add_text_and_export() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -509,12 +532,16 @@ fn image_fit_contain_preserves_aspect_ratio() { #[test] fn preview_image_writer_supports_jpeg_scale_and_svg() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; - let source_path = temp_dir.path().join("preview.png"); - image::RgbaImage::from_pixel(80, 40, image::Rgba([0x22, 0x66, 0xAA, 0xFF])) - .save(&source_path)?; + let mut source_bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 80, + 40, + image::Rgba([0x22, 0x66, 0xAA, 0xFF]), + )) + .write_to(&mut source_bytes, image::ImageFormat::Png)?; let target_path = temp_dir.path().join("preview.jpg"); - write_preview_image( - &source_path, + write_preview_image_bytes( + &source_bytes.into_inner(), &target_path, PreviewOutputFormat::Jpeg, 0.5, @@ -527,14 +554,17 @@ fn preview_image_writer_supports_jpeg_scale_and_svg() -> Result<(), Box Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Preview", + "theme": { + "color_scheme": { + "bg1": "F7F4EA", + "tx1": "1E1E1E" + } + } + }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ + "background_fill": "#EAE4D5" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 80, "top": 110, "width": 180, "height": 90 }, + "fill": "#CC5533", + "text": "Preview card", + "text_style": { "color": "#FFFFFF", "alignment": "center", "bold": true } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Quarterly update", + "position": { "left": 72, "top": 40, "width": 320, "height": 44 }, + "style": "title" + }), + }, + temp_dir.path(), + )?; + + let response = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "render_preview".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let preview = response.rendered_preview.expect("rendered preview present"); + let rendered = load_preview(&preview.png_bytes)?; + + assert_eq!(preview.slide_index, 0); + assert_eq!(rendered.dimensions(), (720, 540)); + assert_eq!(rendered.get_pixel(20, 20).0, [0xEA, 0xE4, 0xD5, 0xFF]); + assert_eq!(rendered.get_pixel(120, 150).0, [0xCC, 0x55, 0x33, 0xFF]); + Ok(()) +} + +#[test] +fn render_preview_renders_image_and_table_content() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let image_path = temp_dir.path().join("preview-source.png"); + image::RgbaImage::from_pixel(40, 30, image::Rgba([0x2A, 0x80, 0xD7, 0xFF])) + .save(&image_path)?; + + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Image and table" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "path": image_path, + "position": { "left": 40, "top": 40, "width": 160, "height": 120 }, + "fit": "contain" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_table".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 240, "top": 60, "width": 220, "height": 120 }, + "rows": [ + ["Metric", "Value"], + ["ARR", "$9.2M"] + ], + "style_options": { "header_row": true } + }), + }, + temp_dir.path(), + )?; + + let response = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "render_preview".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let preview = load_preview( + &response + .rendered_preview + .expect("rendered preview present") + .png_bytes, + )?; + + assert_eq!(preview.get_pixel(80, 80).0, [0x2A, 0x80, 0xD7, 0xFF]); + assert_eq!(preview.get_pixel(270, 70).0, [0xEA, 0xF0, 0xF8, 0xFF]); + Ok(()) +} + +#[test] +fn render_preview_renders_chart_primitives() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Chart preview" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_chart".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "chart_type": "bar", + "position": { "left": 120, "top": 80, "width": 360, "height": 220 }, + "title": "Pipeline", + "categories": ["Q1", "Q2", "Q3"], + "series": [ + { "name": "Actual", "values": [5, 7, 9], "fill": "#4E79A7" } + ] + }), + }, + temp_dir.path(), + )?; + + let response = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "render_preview".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let preview = load_preview( + &response + .rendered_preview + .expect("rendered preview present") + .png_bytes, + )?; + + assert!( + rect_contains_pixel(&preview, 150, 120, 220, 180, [0x4E, 0x79, 0xA7, 0xFF]), + "expected to find the series fill color within the chart plot area" + ); + Ok(()) +} + +#[test] +fn export_preview_uses_native_renderer_for_single_slide() -> Result<(), Box> +{ + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Export preview" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ + "background_fill": "#F2E8DC" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 96, "top": 120, "width": 160, "height": 80 }, + "fill": "#2F6B5F" + }), + }, + temp_dir.path(), + )?; + + let target_path = temp_dir.path().join("preview.png"); + let response = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_preview".to_string(), + args: serde_json::json!({ + "path": target_path, + "slide_index": 0 + }), + }, + temp_dir.path(), + )?; + let exported_path = response + .exported_paths + .first() + .expect("exported preview path present"); + let preview = image::open(exported_path)?; + + assert_eq!(response.exported_paths.len(), 1); + assert_eq!(preview.dimensions(), (720, 540)); + assert_eq!(preview.get_pixel(20, 20).0, [0xF2, 0xE8, 0xDC, 0xFF]); + assert_eq!(preview.get_pixel(120, 150).0, [0x2F, 0x6B, 0x5F, 0xFF]); + Ok(()) +} + +#[test] +fn export_preview_writes_all_slides_with_stable_names() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Export all previews" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ + "background_fill": "#FFF4CC" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ + "background_fill": "#D9EAF7" + }), + }, + temp_dir.path(), + )?; + + let output_dir = temp_dir.path().join("previews"); + let response = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_preview".to_string(), + args: serde_json::json!({ + "path": output_dir + }), + }, + temp_dir.path(), + )?; + + assert_eq!(response.exported_paths.len(), 2); + assert_eq!( + response.exported_paths, + vec![ + temp_dir.path().join("previews/slide-1.png"), + temp_dir.path().join("previews/slide-2.png"), + ] + ); + let slide_1 = image::open(&response.exported_paths[0])?; + let slide_2 = image::open(&response.exported_paths[1])?; + assert_eq!(slide_1.get_pixel(20, 20).0, [0xFF, 0xF4, 0xCC, 0xFF]); + assert_eq!(slide_2.get_pixel(20, 20).0, [0xD9, 0xEA, 0xF7, 0xFF]); + Ok(()) +} diff --git a/codex-rs/core/src/tools/handlers/presentation_artifact.rs b/codex-rs/core/src/tools/handlers/presentation_artifact.rs index 23b80d6912..86224eb80f 100644 --- a/codex-rs/core/src/tools/handlers/presentation_artifact.rs +++ b/codex-rs/core/src/tools/handlers/presentation_artifact.rs @@ -1,8 +1,12 @@ use async_trait::async_trait; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_artifact_presentation::PathAccessKind; use codex_artifact_presentation::PathAccessRequirement; use codex_artifact_presentation::PresentationArtifactError; use codex_artifact_presentation::PresentationArtifactToolRequest; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::openai_models::InputModality; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use serde_json::to_string; @@ -68,6 +72,19 @@ impl ToolHandler for PresentationArtifactHandler { }; let request: PresentationArtifactToolRequest = parse_arguments(&arguments)?; + if request + .actions + .iter() + .any(|action| action.action == "render_preview") + && !turn + .model_info + .input_modalities + .contains(&InputModality::Image) + { + return Err(FunctionCallError::RespondToModel( + "render_preview is not allowed because you do not support image inputs".to_string(), + )); + } for access in request .required_path_accesses(&turn.cwd) .map_err(presentation_error)? @@ -85,6 +102,26 @@ impl ToolHandler for PresentationArtifactHandler { .await .map_err(presentation_error)?; + if let Some(preview) = response.rendered_preview.as_ref() { + let summary = format!( + "{} Artifact `{}` slide {}.", + response.summary, + response.artifact_id, + preview.slide_index + 1 + ); + let image_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(&preview.png_bytes) + ); + return Ok(ToolOutput::Function { + body: FunctionCallOutputBody::ContentItems(vec![ + FunctionCallOutputContentItem::InputText { text: summary }, + FunctionCallOutputContentItem::InputImage { image_url }, + ]), + success: Some(true), + }); + } + Ok(ToolOutput::Function { body: FunctionCallOutputBody::Text(to_string(&response).map_err(|error| { FunctionCallError::RespondToModel(format!( diff --git a/codex-rs/core/templates/tools/presentation_artifact.md b/codex-rs/core/templates/tools/presentation_artifact.md index 45a91bc43b..a5aca8916c 100644 --- a/codex-rs/core/templates/tools/presentation_artifact.md +++ b/codex-rs/core/templates/tools/presentation_artifact.md @@ -198,3 +198,10 @@ Example preview: Example JPEG preview: `{"artifact_id":"presentation_x","actions":[{"action":"export_preview","args":{"slide_index":0,"path":"artifacts/q2-update-slide1.jpg","format":"jpeg","scale":0.75,"quality":85}}]}` + +For model-visible previews, use `render_preview`. It renders a slide natively and returns inline image output instead of writing a file. + +Example inline preview: +`{"artifact_id":"presentation_x","actions":[{"action":"render_preview","args":{"slide_index":0,"scale":1.0}}]}` + +`render_preview` defaults to the active slide when `slide_index` is omitted. It supports `scale` and `include_background`. diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e9867caa76..4e8f2a3b94 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -96,6 +96,7 @@ mod permissions_messages; mod personality; mod personality_migration; mod plugins; +mod presentation_artifact; mod prompt_caching; mod quota_exceeded; mod read_file; diff --git a/codex-rs/core/tests/suite/presentation_artifact.rs b/codex-rs/core/tests/suite/presentation_artifact.rs new file mode 100644 index 0000000000..f3d6e743f9 --- /dev/null +++ b/codex-rs/core/tests/suite/presentation_artifact.rs @@ -0,0 +1,278 @@ +#![cfg(not(target_os = "windows"))] + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_core::CodexAuth; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_models_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use image::GenericImageView; +use image::load_from_memory; +use pretty_assertions::assert_eq; +use serde_json::Value; +use wiremock::BodyPrintLimit; +use wiremock::MockServer; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn presentation_artifact_render_preview_returns_inline_image() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let TestCodex { + codex, + cwd, + session_configured, + .. + } = test_codex().build(&server).await?; + + let call_id = "presentation-render-preview"; + let arguments = serde_json::json!({ + "actions": [ + { + "action": "create", + "args": { + "name": "Preview", + "theme": { + "color_scheme": { + "bg1": "F6F2E8", + "tx1": "1B1B1B" + } + } + } + }, + { + "action": "add_slide", + "args": { + "background_fill": "#F1E6D6" + } + }, + { + "action": "add_shape", + "args": { + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 72, "top": 120, "width": 160, "height": 80 }, + "fill": "#C95A3D" + } + }, + { + "action": "render_preview", + "args": {} + } + ] + }) + .to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "presentation_artifact", &arguments), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "render the deck preview".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_configured.model.clone(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let function_output = mock.single_request().function_call_output(call_id); + let output_items = function_output + .get("output") + .and_then(Value::as_array) + .expect("render_preview output should be content items"); + assert_eq!(output_items.len(), 2); + assert_eq!( + output_items[0].get("type").and_then(Value::as_str), + Some("input_text") + ); + assert_eq!( + output_items[1].get("type").and_then(Value::as_str), + Some("input_image") + ); + let image_url = output_items[1] + .get("image_url") + .and_then(Value::as_str) + .expect("preview image_url present"); + let (prefix, payload) = image_url + .split_once(',') + .expect("preview image contains data prefix"); + assert_eq!(prefix, "data:image/png;base64"); + + let decoded = BASE64_STANDARD.decode(payload)?; + let rendered = load_from_memory(&decoded)?; + assert_eq!(rendered.dimensions(), (720, 540)); + assert_eq!(rendered.get_pixel(20, 20).0, [0xF1, 0xE6, 0xD6, 0xFF]); + assert_eq!(rendered.get_pixel(100, 150).0, [0xC9, 0x5A, 0x3D, 0xFF]); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn presentation_artifact_render_preview_fails_for_text_only_model() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await; + let model_slug = "text-only-presentation-preview-test-model"; + let text_only_model = ModelInfo { + slug: model_slug.to_string(), + display_name: "Text-only presentation preview test model".to_string(), + description: Some( + "Remote model for presentation preview unsupported-path coverage".to_string(), + ), + default_reasoning_level: Some(ReasoningEffort::Medium), + supported_reasoning_levels: vec![ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: ReasoningEffort::Medium.to_string(), + }], + shell_type: ConfigShellToolType::ShellCommand, + visibility: ModelVisibility::List, + supported_in_api: true, + input_modalities: vec![InputModality::Text], + prefer_websockets: false, + used_fallback_model_metadata: false, + priority: 1, + upgrade: None, + base_instructions: "base instructions".to_string(), + model_messages: None, + supports_reasoning_summaries: false, + default_reasoning_summary: ReasoningSummary::Auto, + support_verbosity: false, + default_verbosity: None, + availability_nux: None, + apply_patch_tool_type: None, + truncation_policy: TruncationPolicyConfig::bytes(10_000), + supports_parallel_tool_calls: false, + context_window: Some(272_000), + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + }; + mount_models_once( + &server, + ModelsResponse { + models: vec![text_only_model], + }, + ) + .await; + + let TestCodex { codex, cwd, .. } = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model = Some(model_slug.to_string()); + }) + .build(&server) + .await?; + + let call_id = "presentation-render-preview-unsupported"; + let arguments = serde_json::json!({ + "actions": [ + { + "action": "create", + "args": { "name": "Preview" } + }, + { + "action": "add_slide", + "args": {} + }, + { + "action": "render_preview", + "args": {} + } + ] + }) + .to_string(); + + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "presentation_artifact", &arguments), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + let mock = responses::mount_sse_once(&server, second_response).await; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "render the deck preview".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: model_slug.to_string(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let output_text = mock + .single_request() + .function_call_output_content_and_success(call_id) + .and_then(|(content, _)| content) + .expect("output text present"); + assert_eq!( + output_text, + "render_preview is not allowed because you do not support image inputs" + ); + + Ok(()) +}