feat: rendering engine

This commit is contained in:
jif-oai
2026-03-03 15:44:59 +00:00
parent 8159f05dfd
commit 9047e4dc6f
17 changed files with 2727 additions and 208 deletions

6
MODULE.bazel.lock generated
View File

@@ -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\"]}}",

61
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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 }

View File

@@ -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)]

View File

@@ -24,6 +24,13 @@ struct ExportPreviewArgs {
quality: Option<u8>,
}
#[derive(Debug, Default, Deserialize)]
struct RenderPreviewArgs {
slide_index: Option<u32>,
scale: Option<f32>,
include_background: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
struct AddSlideArgs {
layout: Option<String>,

View File

@@ -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<PresentationArtifactResponse, PresentationArtifactError> {
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,
})
}

View File

@@ -8,3 +8,4 @@ include!("proto.rs");
include!("inspect.rs");
include!("pptx.rs");
include!("snapshot.rs");
include!("render.rs");

View File

@@ -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"
)
}

View File

@@ -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<Vec<PathBuf>, 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::<Vec<_>>();
files.sort();
Ok(files)
}
fn parse_preview_output_format(
format: Option<&str>,
path: &Path,

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,14 @@ pub struct PresentationArtifactResponse {
pub patch: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub active_slide_index: Option<usize>,
#[serde(skip)]
pub rendered_preview: Option<RenderedPreview>,
}
#[derive(Debug, Clone)]
pub struct RenderedPreview {
pub slide_index: usize,
pub png_bytes: Vec<u8>,
}
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,
}
}

View File

@@ -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<Vec<serde_json::Value>, serde_json
.collect()
}
fn load_preview(bytes: &[u8]) -> Result<image::DynamicImage, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std:
image::ImageFormat::from_path(&target_path)?,
image::ImageFormat::Jpeg
);
assert!(!source_path.exists());
let svg_source_path = temp_dir.path().join("preview-svg.png");
image::RgbaImage::from_pixel(32, 16, image::Rgba([0x55, 0xAA, 0x44, 0xFF]))
.save(&svg_source_path)?;
let mut svg_source_bytes = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
32,
16,
image::Rgba([0x55, 0xAA, 0x44, 0xFF]),
))
.write_to(&mut svg_source_bytes, image::ImageFormat::Png)?;
let svg_target_path = temp_dir.path().join("preview.svg");
write_preview_image(
&svg_source_path,
write_preview_image_bytes(
&svg_source_bytes.into_inner(),
&svg_target_path,
PreviewOutputFormat::Svg,
0.5,
@@ -544,7 +574,6 @@ fn preview_image_writer_supports_jpeg_scale_and_svg() -> Result<(), Box<dyn std:
let svg = std::fs::read_to_string(&svg_target_path)?;
assert!(svg.contains(r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="8""#));
assert!(svg.contains("data:image/png;base64,"));
assert!(!svg_source_path.exists());
Ok(())
}
@@ -3510,3 +3539,341 @@ fn proto_and_patch_actions_work_and_patch_history_is_atomic()
assert_eq!(redone_proto.proto_json, Some(proto));
Ok(())
}
#[test]
fn render_preview_returns_png_for_active_slide() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>>
{
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<dyn std::error::Error>> {
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(())
}

View File

@@ -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!(

View File

@@ -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`.

View File

@@ -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;

View File

@@ -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(())
}