Compare commits

...

9 Commits

Author SHA1 Message Date
jif-oai
8c32263808 Merge branch 'main' into jif/rendering-engine 2026-03-03 23:16:11 +00:00
jif-oai
bc52a662bb Merge branch 'main' into jif/rendering-engine 2026-03-03 21:56:49 +00:00
jif-oai
a99525eb00 fixes 2026-03-03 20:50:11 +00:00
jif-oai
1452f52d2c go further 2 2026-03-03 20:42:08 +00:00
jif-oai
59a1058b30 go further 2026-03-03 20:16:57 +00:00
jif-oai
61d77d8daf nit 2026-03-03 20:01:31 +00:00
jif-oai
aa14b3cd2a fix more 2026-03-03 18:23:38 +00:00
jif-oai
57a13ada75 feat: massive improvement 2026-03-03 17:29:40 +00:00
jif-oai
9047e4dc6f feat: rendering engine 2026-03-03 15:44:59 +00:00
25 changed files with 9612 additions and 594 deletions

8
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,8 @@
"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\":[]}}",
"fontdue_0.9.3": "{\"dependencies\":[{\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"opentype-layout\"],\"name\":\"ttf-parser\",\"req\":\"^0.21\"}],\"features\":{\"default\":[\"simd\",\"hashbrown\"],\"parallel\":[\"rayon\",\"hashbrown\",\"hashbrown/rayon\"],\"simd\":[],\"std\":[]}}",
"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 +1133,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 +1328,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 +1376,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\"]}}",
@@ -1414,6 +1421,7 @@
"try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}",
"ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}",
"ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}",
"ttf-parser_0.21.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"kind\":\"dev\",\"name\":\"pico-args\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"tiny-skia-path\",\"req\":\"^0.11.4\"},{\"kind\":\"dev\",\"name\":\"xmlwriter\",\"req\":\"^0.1\"}],\"features\":{\"apple-layout\":[],\"default\":[\"std\",\"opentype-layout\",\"apple-layout\",\"variable-fonts\",\"glyph-names\"],\"glyph-names\":[],\"gvar-alloc\":[\"std\"],\"opentype-layout\":[],\"std\":[],\"variable-fonts\":[]}}",
"two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}",
"type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}",
"typenum_1.19.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}",

80
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"
@@ -1543,15 +1549,16 @@ name = "codex-artifact-presentation"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"font8x8",
"fontdue",
"image",
"ppt-rs",
"pretty_assertions",
"reqwest 0.12.28",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tiny_http",
"tiny-skia",
"uuid",
"zip 2.4.2",
]
@@ -4086,6 +4093,22 @@ 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 = "fontdue"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b"
dependencies = [
"hashbrown 0.15.5",
"ttf-parser",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -5083,7 +5106,7 @@ dependencies = [
"image-webp",
"moxcms",
"num-traits",
"png",
"png 0.18.0",
"tiff",
"zune-core 0.5.1",
"zune-jpeg 0.5.12",
@@ -7027,6 +7050,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"
@@ -9510,6 +9546,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"
@@ -9989,6 +10031,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"
@@ -10507,6 +10575,12 @@ dependencies = [
"termcolor",
]
[[package]]
name = "ttf-parser"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
[[package]]
name = "tungstenite"
version = "0.27.0"

View File

@@ -218,6 +218,9 @@ os_info = "3.12.0"
owo-colors = "4.3.0"
path-absolutize = "3.1.1"
pathdiff = "0.2"
font8x8 = "0.3.1"
fontdue = "0.9.3"
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 }
fontdue = { 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 }
@@ -25,4 +27,3 @@ zip = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tiny_http = { workspace = true }

View File

@@ -4,9 +4,6 @@ use image::GenericImageView;
use image::ImageFormat;
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use ppt_rs::Chart;
use ppt_rs::ChartSeries;
use ppt_rs::ChartType;
use ppt_rs::Hyperlink as PptHyperlink;
use ppt_rs::HyperlinkAction as PptHyperlinkAction;
use ppt_rs::Image;
@@ -40,7 +37,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 +71,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)]
@@ -163,8 +161,16 @@ impl PresentationArtifactRequest {
}],
ImageInputSource::DataUrl(_)
| ImageInputSource::Blob(_)
| ImageInputSource::Uri(_)
| ImageInputSource::Placeholder => Vec::new(),
ImageInputSource::Uri(uri) => {
return Err(PresentationArtifactError::UnsupportedFeature {
action: self.action.clone(),
message: format!(
"remote image URIs are not supported for `{}`; download the image locally or provide `data_url`/`blob` instead (`{uri}`)",
self.action
),
});
}
}
}
"replace_image" => {
@@ -183,8 +189,16 @@ impl PresentationArtifactRequest {
}],
(None, Some(_), None, None, None)
| (None, None, Some(_), None, None)
| (None, None, None, Some(_), None)
| (None, None, None, None, Some(_)) => Vec::new(),
(None, None, None, Some(uri), None) => {
return Err(PresentationArtifactError::UnsupportedFeature {
action: self.action.clone(),
message: format!(
"remote image URIs are not supported for `{}`; download the image locally or provide `data_url`/`blob` instead (`{uri}`)",
self.action
),
});
}
_ => {
return Err(PresentationArtifactError::InvalidArgs {
action: self.action.clone(),

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,9 +100,11 @@ 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),
"list_masters" => self.list_masters(request),
"list_layout_placeholders" => self.list_layout_placeholders(request),
"list_slide_placeholders" => self.list_slide_placeholders(request),
"inspect" => self.inspect(request),
@@ -148,6 +150,8 @@ impl PresentationArtifactManager {
"insert_text_after" => self.insert_text_after(request),
"set_hyperlink" => self.set_hyperlink(request),
"set_comment_author" => self.set_comment_author(request),
"list_comment_threads" => self.list_comment_threads(request),
"get_comment_thread" => self.get_comment_thread(request),
"add_comment_thread" => self.add_comment_thread(request),
"add_comment_reply" => self.add_comment_reply(request),
"toggle_comment_reaction" => self.toggle_comment_reaction(request),
@@ -215,6 +219,9 @@ impl PresentationArtifactManager {
}
})?;
let mut document = PresentationDocument::from_ppt_rs(imported);
if let Some(slide_size) = import_pptx_slide_size(&path)? {
document.slide_size = slide_size;
}
import_pptx_images(&path, &mut document, &request.action)?;
document
};
@@ -286,82 +293,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,
include_background: true,
},
)?;
write_preview_image_bytes(
&png_bytes,
&output_path,
preview_format,
scale,
1.0,
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 +333,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,
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,
1.0,
quality,
&request.action,
)?;
relocated.push(target);
exported_paths.push(target);
}
exported_paths = relocated;
}
let mut response = PresentationArtifactResponse::new(
artifact_id,
@@ -404,6 +365,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,
@@ -468,6 +459,24 @@ impl PresentationArtifactManager {
Ok(response)
}
fn list_masters(
&mut self,
request: PresentationArtifactRequest,
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
let artifact_id = required_artifact_id(&request)?;
let document = self.get_document(&artifact_id, &request.action)?;
let masters = master_layout_list(document);
let mut response = PresentationArtifactResponse::new(
artifact_id,
request.action,
format!("Listed {} masters", masters.len()),
snapshot_for_document(document),
);
response.layout_list = Some(masters);
response.theme = Some(document.theme_snapshot());
Ok(response)
}
fn list_layout_placeholders(
&mut self,
request: PresentationArtifactRequest,
@@ -1583,10 +1592,6 @@ impl PresentationArtifactManager {
ImageInputSource::Uri(uri) => Some(load_image_payload_from_uri(&uri, "replace_image")?),
ImageInputSource::Placeholder => None,
};
let fit_mode = args.fit.unwrap_or(ImageFitMode::Stretch);
let lock_aspect_ratio = args
.lock_aspect_ratio
.unwrap_or(fit_mode != ImageFitMode::Stretch);
let crop = args
.crop
.map(|crop| normalize_image_crop(crop, &request.action))
@@ -1600,8 +1605,10 @@ impl PresentationArtifactManager {
});
};
image.payload = image_payload;
image.fit_mode = fit_mode;
image.crop = crop;
image.fit_mode = args.fit.unwrap_or(image.fit_mode);
if let Some(crop) = crop {
image.crop = Some(crop);
}
if let Some(rotation) = args.rotation {
image.rotation_degrees = Some(rotation);
}
@@ -1611,9 +1618,15 @@ impl PresentationArtifactManager {
if let Some(flip_vertical) = args.flip_vertical {
image.flip_vertical = flip_vertical;
}
image.lock_aspect_ratio = lock_aspect_ratio;
image.alt_text = args.alt;
image.prompt = args.prompt;
if let Some(lock_aspect_ratio) = args.lock_aspect_ratio {
image.lock_aspect_ratio = lock_aspect_ratio;
}
if let Some(alt) = args.alt {
image.alt_text = Some(alt);
}
if let Some(prompt) = args.prompt {
image.prompt = Some(prompt);
}
image.is_placeholder = is_placeholder;
Ok(PresentationArtifactResponse::new(
artifact_id,
@@ -1865,12 +1878,43 @@ impl PresentationArtifactManager {
message: format!("element `{}` is not a table", args.element_id),
});
};
let row_count = table.rows.len();
let column_count = table.rows.first().map(Vec::len).unwrap_or(0);
let start_row = args.start_row as usize;
let end_row = args.end_row as usize;
let start_column = args.start_column as usize;
let end_column = args.end_column as usize;
if start_row > end_row || start_column > end_column {
return Err(PresentationArtifactError::InvalidArgs {
action: request.action,
message: "merge bounds must be ordered from top-left to bottom-right".to_string(),
});
}
if end_row >= row_count || end_column >= column_count {
return Err(PresentationArtifactError::InvalidArgs {
action: request.action,
message: format!(
"merge bounds [{start_row},{start_column}]..=[{end_row},{end_column}] exceed table size {row_count}x{column_count}"
),
});
}
let region = TableMergeRegion {
start_row: args.start_row as usize,
end_row: args.end_row as usize,
start_column: args.start_column as usize,
end_column: args.end_column as usize,
start_row,
end_row,
start_column,
end_column,
};
if table.merges.iter().any(|merge| {
merge.start_row <= region.end_row
&& region.start_row <= merge.end_row
&& merge.start_column <= region.end_column
&& region.start_column <= merge.end_column
}) {
return Err(PresentationArtifactError::InvalidArgs {
action: request.action,
message: format!("merge region overlaps an existing merge in `{}`", args.element_id),
});
}
table.merges.push(region);
Ok(PresentationArtifactResponse::new(
artifact_id,
@@ -2527,6 +2571,54 @@ impl PresentationArtifactManager {
))
}
fn list_comment_threads(
&mut self,
request: PresentationArtifactRequest,
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
let artifact_id = required_artifact_id(&request)?;
let document = self.get_document(&artifact_id, &request.action)?;
let mut response = PresentationArtifactResponse::new(
artifact_id,
request.action,
format!("Listed {} comment threads", document.comment_threads.len()),
snapshot_for_document(document),
);
response.resolved_record = Some(serde_json::json!({
"commentSelf": document.comment_self.as_ref().map(comment_author_to_proto),
"commentThreads": document
.comment_threads
.iter()
.map(comment_thread_to_proto)
.collect::<Vec<_>>(),
}));
Ok(response)
}
fn get_comment_thread(
&mut self,
request: PresentationArtifactRequest,
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
let args: CommentThreadIdArgs = parse_args(&request.action, &request.args)?;
let artifact_id = required_artifact_id(&request)?;
let document = self.get_document(&artifact_id, &request.action)?;
let thread = document
.comment_threads
.iter()
.find(|thread| thread.thread_id == args.thread_id)
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: request.action.clone(),
message: format!("unknown comment thread `{}`", args.thread_id),
})?;
let mut response = PresentationArtifactResponse::new(
artifact_id,
request.action,
format!("Retrieved comment thread `{}`", args.thread_id),
snapshot_for_document(document),
);
response.resolved_record = Some(comment_thread_to_proto(thread));
Ok(response)
}
fn add_comment_thread(
&mut self,
request: PresentationArtifactRequest,
@@ -2757,9 +2849,40 @@ impl PresentationArtifactManager {
|| text_layout.wrap.is_some()
|| text_layout.auto_fit.is_some()
|| text_layout.vertical_alignment.is_some();
let position_rotation = args
.position
.as_ref()
.and_then(|position| position.rotation);
let position_flip_horizontal = args
.position
.as_ref()
.and_then(|position| position.flip_horizontal);
let position_flip_vertical = args
.position
.as_ref()
.and_then(|position| position.flip_vertical);
let has_position_transform = position_rotation.is_some()
|| position_flip_horizontal.is_some()
|| position_flip_vertical.is_some();
let has_image_fields =
args.fit.is_some() || args.crop.is_some() || args.lock_aspect_ratio.is_some();
let element = document.find_element_mut(&args.element_id, &request.action)?;
match element {
PresentationElement::Text(text) => {
if args.stroke.is_some()
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
|| has_position_transform
|| has_image_fields
{
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
message:
"text elements support only `position`, `z_order`, `fill`, and `text_layout` updates"
.to_string(),
});
}
if let Some(position) = args.position {
text.frame = apply_partial_position(text.frame, position);
}
@@ -2769,32 +2892,23 @@ impl PresentationArtifactManager {
if has_text_layout {
text.rich_text.layout = text_layout;
}
if args.stroke.is_some()
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
{
}
PresentationElement::Shape(shape) => {
if has_image_fields {
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
message:
"text elements support only `position`, `z_order`, and `fill` updates"
"shape elements support only `position`, `fill`, `stroke`, `rotation`, `flip_horizontal`, `flip_vertical`, `z_order`, and `text_layout` updates"
.to_string(),
});
}
}
PresentationElement::Shape(shape) => {
let position_rotation = args
.position
.as_ref()
.and_then(|position| position.rotation);
let position_flip_horizontal = args
.position
.as_ref()
.and_then(|position| position.flip_horizontal);
let position_flip_vertical = args
.position
.as_ref()
.and_then(|position| position.flip_vertical);
if has_text_layout && shape.text.is_none() {
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
message: "shape elements without text do not support `text_layout` updates"
.to_string(),
});
}
if let Some(position) = args.position {
shape.frame = apply_partial_position(shape.frame, position);
}
@@ -2825,9 +2939,9 @@ impl PresentationArtifactManager {
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
|| args.fit.is_some()
|| args.crop.is_some()
|| args.lock_aspect_ratio.is_some()
|| has_position_transform
|| has_image_fields
|| has_text_layout
{
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
@@ -2860,19 +2974,7 @@ impl PresentationArtifactManager {
}
}
PresentationElement::Image(image) => {
let position_rotation = args
.position
.as_ref()
.and_then(|position| position.rotation);
let position_flip_horizontal = args
.position
.as_ref()
.and_then(|position| position.flip_horizontal);
let position_flip_vertical = args
.position
.as_ref()
.and_then(|position| position.flip_vertical);
if args.fill.is_some() || args.stroke.is_some() {
if args.fill.is_some() || args.stroke.is_some() || has_text_layout {
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
message:
@@ -2911,6 +3013,9 @@ impl PresentationArtifactManager {
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
|| has_position_transform
|| has_image_fields
|| has_text_layout
{
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
@@ -2928,6 +3033,9 @@ impl PresentationArtifactManager {
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
|| has_position_transform
|| has_image_fields
|| has_text_layout
{
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
@@ -3031,6 +3139,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

@@ -85,14 +85,14 @@ struct NamedTextStyle {
built_in: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct HyperlinkState {
target: HyperlinkTarget,
tooltip: Option<String>,
highlight_click: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
enum HyperlinkTarget {
Url(String),
Slide(u32),
@@ -132,6 +132,44 @@ impl HyperlinkTarget {
fn is_external(&self) -> bool {
matches!(self, Self::Url(_) | Self::Email { .. } | Self::File(_))
}
fn adjust_for_insert(&mut self, inserted_index: usize) {
if let Self::Slide(slide_index) = self
&& *slide_index as usize >= inserted_index
{
*slide_index += 1;
}
}
fn adjust_for_move(&mut self, from_index: usize, to_index: usize) {
let Self::Slide(slide_index_ref) = self else {
return;
};
let slide_index = *slide_index_ref as usize;
*slide_index_ref = if slide_index == from_index {
to_index as u32
} else if from_index < to_index && (from_index + 1..=to_index).contains(&slide_index) {
(slide_index - 1) as u32
} else if to_index < from_index && (to_index..from_index).contains(&slide_index) {
(slide_index + 1) as u32
} else {
*slide_index_ref
};
}
fn adjust_for_delete(&mut self, deleted_index: usize) -> bool {
let Self::Slide(slide_index_ref) = self else {
return false;
};
let slide_index = *slide_index_ref as usize;
if slide_index == deleted_index {
return true;
}
if slide_index > deleted_index {
*slide_index_ref -= 1;
}
false
}
}
impl HyperlinkState {
@@ -217,6 +255,18 @@ impl HyperlinkState {
ppt_rs::escape_xml(&self.target.relationship_target()),
)
}
fn adjust_for_insert(&mut self, inserted_index: usize) {
self.target.adjust_for_insert(inserted_index);
}
fn adjust_for_move(&mut self, from_index: usize, to_index: usize) {
self.target.adjust_for_move(from_index, to_index);
}
fn adjust_for_delete(&mut self, deleted_index: usize) -> bool {
self.target.adjust_for_delete(deleted_index)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -285,7 +335,7 @@ enum TextVerticalAlignment {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CommentAuthorProfile {
pub(super) struct CommentAuthorProfile {
display_name: String,
initials: String,
email: Option<String>,
@@ -332,7 +382,7 @@ enum CommentTarget {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CommentThread {
pub(super) struct CommentThread {
thread_id: String,
target: CommentTarget,
position: Option<CommentPosition>,
@@ -714,6 +764,7 @@ impl PresentationDocument {
}
Some(_) => {}
}
self.adjust_hyperlinks_for_insert(inserted_index);
}
fn adjust_active_slide_for_move(&mut self, from_index: usize, to_index: usize) {
@@ -728,6 +779,7 @@ impl PresentationDocument {
active_index
});
}
self.adjust_hyperlinks_for_move(from_index, to_index);
}
fn adjust_active_slide_for_delete(&mut self, deleted_index: usize) {
@@ -740,6 +792,36 @@ impl PresentationDocument {
Some(active_index) if deleted_index < active_index => Some(active_index - 1),
Some(active_index) => Some(active_index),
};
self.adjust_hyperlinks_for_delete(deleted_index);
}
fn adjust_hyperlinks_for_insert(&mut self, inserted_index: usize) {
self.visit_hyperlinks_mut(|hyperlink| {
hyperlink.adjust_for_insert(inserted_index);
true
});
}
fn adjust_hyperlinks_for_move(&mut self, from_index: usize, to_index: usize) {
self.visit_hyperlinks_mut(|hyperlink| {
hyperlink.adjust_for_move(from_index, to_index);
true
});
}
fn adjust_hyperlinks_for_delete(&mut self, deleted_index: usize) {
self.visit_hyperlinks_mut(|hyperlink| !hyperlink.adjust_for_delete(deleted_index));
}
fn visit_hyperlinks_mut<F>(&mut self, mut visit: F)
where
F: FnMut(&mut HyperlinkState) -> bool,
{
for slide in &mut self.slides {
for element in &mut slide.elements {
element.visit_hyperlinks_mut(&mut visit);
}
}
}
fn next_layout_id(&mut self) -> String {
@@ -926,16 +1008,16 @@ impl PresentationDocument {
})
}
fn to_ppt_rs(&self) -> Presentation {
fn to_ppt_rs(&self) -> Result<Presentation, PresentationArtifactError> {
let mut presentation = self
.name
.as_deref()
.map(Presentation::with_title)
.unwrap_or_default();
for slide in &self.slides {
presentation = presentation.add_slide(slide.to_ppt_rs(self.slide_size));
presentation = presentation.add_slide(slide.to_ppt_rs(self.slide_size, self)?);
}
presentation
Ok(presentation)
}
}
@@ -1053,6 +1135,38 @@ fn import_pptx_images(
Ok(())
}
fn import_pptx_slide_size(path: &Path) -> Result<Option<Rect>, PresentationArtifactError> {
let file = std::fs::File::open(path).map_err(|error| PresentationArtifactError::ImportFailed {
path: path.to_path_buf(),
message: error.to_string(),
})?;
let mut archive =
ZipArchive::new(file).map_err(|error| PresentationArtifactError::ImportFailed {
path: path.to_path_buf(),
message: error.to_string(),
})?;
let Some(xml) = zip_entry_string_if_exists(&mut archive, "ppt/presentation.xml").map_err(
|message| PresentationArtifactError::ImportFailed {
path: path.to_path_buf(),
message,
},
)?
else {
return Ok(None);
};
let Some(width) =
xml_tag_attribute(&xml, "<p:sldSz", "cx").and_then(|value| value.parse::<u32>().ok())
else {
return Ok(None);
};
let Some(height) =
xml_tag_attribute(&xml, "<p:sldSz", "cy").and_then(|value| value.parse::<u32>().ok())
else {
return Ok(None);
};
Ok(Some(Rect::from_emu(0, 0, width, height)))
}
fn zip_entry_string_if_exists<R: Read + Seek>(
archive: &mut ZipArchive<R>,
path: &str,
@@ -1223,7 +1337,11 @@ fn xml_attribute(tag: &str, attribute: &str) -> Option<String> {
}
impl PresentationSlide {
fn to_ppt_rs(&self, slide_size: Rect) -> SlideContent {
fn to_ppt_rs(
&self,
slide_size: Rect,
document: &PresentationDocument,
) -> Result<SlideContent, PresentationArtifactError> {
let mut content = SlideContent::new("").layout(SlideLayout::Blank);
if self.notes.visible && !self.notes.text.is_empty() {
content = content.notes(&self.notes.text);
@@ -1391,24 +1509,22 @@ impl PresentationSlide {
content = content.table(builder.build());
}
PresentationElement::Chart(chart) => {
let mut ppt_chart = Chart::new(
chart.title.as_deref().unwrap_or("Chart"),
chart.chart_type.to_ppt_rs(),
chart.categories,
points_to_emu(chart.frame.left),
points_to_emu(chart.frame.top),
let chart_bytes = render_chart_png_bytes(document, &chart)?;
let ppt_chart = Image::from_bytes(
chart_bytes,
points_to_emu(chart.frame.width),
points_to_emu(chart.frame.height),
"png",
)
.position(
points_to_emu(chart.frame.left),
points_to_emu(chart.frame.top),
);
for series in chart.series {
ppt_chart =
ppt_chart.add_series(ChartSeries::new(&series.name, series.values));
}
content = content.add_chart(ppt_chart);
content = content.add_image(ppt_chart);
}
}
}
content
Ok(content)
}
}
@@ -1477,6 +1593,45 @@ impl PresentationElement {
Self::Chart(element) => element.z_order = z_order,
}
}
fn visit_hyperlinks_mut<F>(&mut self, visit: &mut F)
where
F: FnMut(&mut HyperlinkState) -> bool,
{
match self {
Self::Text(element) => {
if let Some(hyperlink) = element.hyperlink.as_mut()
&& !visit(hyperlink)
{
element.hyperlink = None;
}
for range in &mut element.rich_text.ranges {
if let Some(hyperlink) = range.hyperlink.as_mut()
&& !visit(hyperlink)
{
range.hyperlink = None;
}
}
}
Self::Shape(element) => {
if let Some(hyperlink) = element.hyperlink.as_mut()
&& !visit(hyperlink)
{
element.hyperlink = None;
}
if let Some(rich_text) = element.rich_text.as_mut() {
for range in &mut rich_text.ranges {
if let Some(hyperlink) = range.hyperlink.as_mut()
&& !visit(hyperlink)
{
range.hyperlink = None;
}
}
}
}
Self::Connector(_) | Self::Image(_) | Self::Table(_) | Self::Chart(_) => {}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -1724,34 +1879,6 @@ enum ChartTypeSpec {
Combo,
}
impl ChartTypeSpec {
fn to_ppt_rs(self) -> ChartType {
match self {
Self::Bar => ChartType::Bar,
Self::BarHorizontal => ChartType::BarHorizontal,
Self::BarStacked => ChartType::BarStacked,
Self::BarStacked100 => ChartType::BarStacked100,
Self::Line => ChartType::Line,
Self::LineMarkers => ChartType::LineMarkers,
Self::LineStacked => ChartType::LineStacked,
Self::Pie => ChartType::Pie,
Self::Doughnut => ChartType::Doughnut,
Self::Area => ChartType::Area,
Self::AreaStacked => ChartType::AreaStacked,
Self::AreaStacked100 => ChartType::AreaStacked100,
Self::Scatter => ChartType::Scatter,
Self::ScatterLines => ChartType::ScatterLines,
Self::ScatterSmooth => ChartType::ScatterSmooth,
Self::Bubble => ChartType::Bubble,
Self::Radar => ChartType::Radar,
Self::RadarFilled => ChartType::RadarFilled,
Self::StockHlc => ChartType::StockHLC,
Self::StockOhlc => ChartType::StockOHLC,
Self::Combo => ChartType::Combo,
}
}
}
impl ConnectorKind {
fn to_ppt_rs(self) -> ConnectorType {
match self {

View File

@@ -25,14 +25,18 @@ fn is_read_only_action(action: &str) -> bool {
"get_summary"
| "list_slides"
| "list_layouts"
| "list_masters"
| "list_layout_placeholders"
| "list_slide_placeholders"
| "list_comment_threads"
| "get_comment_thread"
| "inspect"
| "resolve"
| "to_proto"
| "get_style"
| "describe_styles"
| "record_patch"
| "render_preview"
)
}
@@ -40,7 +44,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

@@ -472,7 +472,7 @@ fn chart_data_label_override_to_proto(override_spec: &ChartDataLabelOverride) ->
})
}
fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value {
pub(super) fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value {
serde_json::json!({
"displayName": author.display_name,
"initials": author.initials,
@@ -480,7 +480,7 @@ fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value {
})
}
fn comment_thread_to_proto(thread: &CommentThread) -> Value {
pub(super) fn comment_thread_to_proto(thread: &CommentThread) -> Value {
serde_json::json!({
"kind": "comment",
"threadId": thread.thread_id,

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

@@ -69,6 +69,13 @@ fn layout_list(document: &PresentationDocument) -> Vec<LayoutListEntry> {
.collect()
}
fn master_layout_list(document: &PresentationDocument) -> Vec<LayoutListEntry> {
layout_list(document)
.into_iter()
.filter(|layout| layout.kind == "master")
.collect()
}
fn points_to_emu(points: u32) -> u32 {
points.saturating_mul(POINT_TO_EMU)
}
@@ -223,54 +230,12 @@ fn load_image_payload_from_uri(
uri: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let response =
reqwest::blocking::get(uri).map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: {error}"),
})?;
let status = response.status();
if !status.is_success() {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: HTTP {status}"),
});
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or(value).trim().to_string());
let bytes = response
.bytes()
.map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to read image `{uri}`: {error}"),
})?;
build_image_payload(
bytes.to_vec(),
infer_remote_image_filename(uri, content_type.as_deref()),
action,
)
}
fn infer_remote_image_filename(uri: &str, content_type: Option<&str>) -> String {
let path_name = reqwest::Url::parse(uri)
.ok()
.and_then(|url| {
url.path_segments()
.and_then(Iterator::last)
.map(str::to_owned)
})
.filter(|segment| !segment.is_empty());
match (path_name, content_type) {
(Some(path_name), _) if Path::new(&path_name).extension().is_some() => path_name,
(Some(path_name), Some(content_type)) => {
format!("{path_name}.{}", image_extension_from_mime(content_type))
}
(Some(path_name), None) => path_name,
(None, Some(content_type)) => format!("image.{}", image_extension_from_mime(content_type)),
(None, None) => "image.png".to_string(),
}
Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!(
"remote image URIs are not supported for `{action}`; download the image locally or provide `data_url`/`blob` instead (`{uri}`)"
),
})
}
fn build_image_payload(

File diff suppressed because it is too large Load Diff

View File

@@ -1704,7 +1704,7 @@ impl SpreadsheetArtifactManager {
let args: CreateDifferentialFormatArgs = parse_args(&request.action, &request.args)?;
let artifact_id = required_artifact_id(&request)?;
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
let style_id = artifact.create_differential_format(args.format);
let style_id = artifact.create_differential_format(args.format)?;
let format = artifact
.get_differential_format(style_id)
.cloned()
@@ -2265,6 +2265,7 @@ impl SpreadsheetArtifactManager {
let action = request.action.clone();
let artifact_id = required_artifact_id(&request)?;
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
artifact.validate_style_index(style_index, &request.action)?;
{
let sheet = artifact.sheet_lookup_mut(
&request.action,

View File

@@ -982,6 +982,9 @@ impl SpreadsheetSheet {
for address in range.addresses() {
let cell = self.get_or_create_cell_mut(address);
cell.formula = formula.clone();
if cell.formula.is_none() {
cell.value = None;
}
}
self.cells.retain(|_, cell| !cell.is_empty());
Ok(())
@@ -1094,6 +1097,9 @@ impl SpreadsheetSheet {
};
let cell = self.get_or_create_cell_mut(address);
cell.formula = formula.clone();
if cell.formula.is_none() {
cell.value = None;
}
}
}
self.cells.retain(|_, cell| !cell.is_empty());
@@ -1741,24 +1747,7 @@ impl SpreadsheetArtifact {
}
match selected.as_str() {
"xlsx" => {
for sheet in &self.sheets {
if !sheet.charts.is_empty()
|| !sheet.tables.is_empty()
|| !sheet.conditional_formats.is_empty()
|| !sheet.pivot_tables.is_empty()
{
return Err(SpreadsheetArtifactError::ExportFailed {
path: path.to_path_buf(),
message: format!(
"xlsx export does not yet support charts, tables, conditional formats, or pivot tables on sheet `{}`; use json or bin export instead",
sheet.name
),
});
}
}
write_xlsx(self, path)
}
"xlsx" => write_xlsx(self, path),
"json" => {
let json = self.to_json()?;
std::fs::write(path, json).map_err(|error| {

View File

@@ -239,6 +239,18 @@ impl SpreadsheetNumberFormat {
}
fn normalized(mut self) -> Self {
if let Some(format_id) = self.format_id
&& let Some(format_code) = builtin_number_format_code(format_id)
{
self.format_code = Some(format_code);
return self;
}
if self.format_id.is_none() {
self.format_id = self
.format_code
.as_deref()
.and_then(builtin_number_format_id);
}
if self.format_code.is_none() {
self.format_code = self.format_id.and_then(builtin_number_format_code);
}
@@ -493,6 +505,7 @@ impl SpreadsheetArtifact {
} else {
format
};
self.validate_cell_format_references(&created, "create_cell_format")?;
Ok(insert_with_next_id(&mut self.cell_formats, created))
}
@@ -500,8 +513,12 @@ impl SpreadsheetArtifact {
self.cell_formats.get(&format_id)
}
pub fn create_differential_format(&mut self, format: SpreadsheetDifferentialFormat) -> u32 {
insert_with_next_id(&mut self.differential_formats, format)
pub fn create_differential_format(
&mut self,
format: SpreadsheetDifferentialFormat,
) -> Result<u32, SpreadsheetArtifactError> {
self.validate_differential_format_references(&format, "create_differential_format")?;
Ok(insert_with_next_id(&mut self.differential_formats, format))
}
pub fn get_differential_format(
@@ -534,6 +551,70 @@ impl SpreadsheetArtifact {
wrap_text: resolved.wrap_text,
})
}
pub fn validate_style_index(
&self,
style_index: u32,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
if style_index == 0 || self.cell_formats.contains_key(&style_index) {
return Ok(());
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("style index `{style_index}` was not found"),
})
}
fn validate_cell_format_references(
&self,
format: &SpreadsheetCellFormat,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
validate_optional_reference(
format.text_style_id,
&self.text_styles,
"text style",
action,
)?;
validate_optional_reference(format.fill_id, &self.fills, "fill", action)?;
validate_optional_reference(format.border_id, &self.borders, "border", action)?;
validate_optional_reference(
format.number_format_id,
&self.number_formats,
"number format",
action,
)?;
validate_optional_reference(
format.base_cell_style_format_id,
&self.cell_formats,
"base cell format",
action,
)?;
Ok(())
}
fn validate_differential_format_references(
&self,
format: &SpreadsheetDifferentialFormat,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
validate_optional_reference(
format.text_style_id,
&self.text_styles,
"text style",
action,
)?;
validate_optional_reference(format.fill_id, &self.fills, "fill", action)?;
validate_optional_reference(format.border_id, &self.borders, "border", action)?;
validate_optional_reference(
format.number_format_id,
&self.number_formats,
"number format",
action,
)?;
Ok(())
}
}
impl SpreadsheetSheet {
@@ -566,6 +647,23 @@ fn resolve_cell_format_recursive(
})
}
fn validate_optional_reference<T>(
id: Option<u32>,
map: &BTreeMap<u32, T>,
kind: &str,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
if let Some(id) = id
&& !map.contains_key(&id)
{
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("{kind} `{id}` was not found"),
});
}
Ok(())
}
fn builtin_number_format_code(format_id: u32) -> Option<String> {
match format_id {
0 => Some("General".to_string()),
@@ -578,3 +676,16 @@ fn builtin_number_format_code(format_id: u32) -> Option<String> {
_ => None,
}
}
fn builtin_number_format_id(format_code: &str) -> Option<u32> {
match format_code {
"General" => Some(0),
"0" => Some(1),
"0.00" => Some(2),
"#,##0" => Some(3),
"#,##0.00" => Some(4),
"0%" => Some(9),
"0.00%" => Some(10),
_ => None,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -14,6 +14,7 @@ Supported actions:
- `get_summary`
- `list_slides`
- `list_layouts`
- `list_masters`
- `list_layout_placeholders`
- `list_slide_placeholders`
- `inspect`
@@ -63,6 +64,8 @@ Supported actions:
- `insert_text_after`
- `set_hyperlink`
- `set_comment_author`
- `list_comment_threads`
- `get_comment_thread`
- `add_comment_thread`
- `add_comment_reply`
- `toggle_comment_reaction`
@@ -104,6 +107,8 @@ Example layout flow:
Layout references in `create_layout.parent_layout_id`, `add_layout_placeholder.layout_id`, `add_slide`, `insert_slide`, `set_slide_layout`, and `list_layout_placeholders` accept either a layout id or a layout name. Name matching prefers exact id, then exact name, then case-insensitive name.
Use `list_masters` when you only want layouts with `kind: "master"` instead of the full mixed layout list.
`insert_slide` accepts `index` or `after_slide_index`. If neither is provided, the new slide is inserted immediately after the active slide, or appended if no active slide is set yet.
Example inspect:
@@ -124,6 +129,14 @@ Rich text is supported on notes, text boxes, shapes with text, and table cells.
Comment threads are supported through `set_comment_author`, `add_comment_thread`, `add_comment_reply`, `toggle_comment_reaction`, `resolve_comment_thread`, and `reopen_comment_thread`. Thread anchors resolve as `th/<thread_id>`, and comment records appear in both `inspect` and `to_proto`.
Use `list_comment_threads` for an explicit collection payload and `get_comment_thread` when you already know the thread id.
Example list comment threads:
`{"artifact_id":"presentation_x","actions":[{"action":"list_comment_threads","args":{}}]}`
Example get comment thread:
`{"artifact_id":"presentation_x","actions":[{"action":"get_comment_thread","args":{"thread_id":"thread_1"}}]}`
Charts support richer series metadata plus `update_chart` and `add_chart_series`, including legend, axis, data-label, marker, fill, and per-point override state.
Exported PPTX files embed Codex metadata so rich text, comment threads, and advanced table/chart state round-trip through `export_pptx` and `import_pptx` even when the base OOXML representation is lossy.
@@ -198,3 +211,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,285 @@
#![cfg(not(target_os = "windows"))]
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_core::CodexAuth;
use codex_core::features::Feature;
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()
.with_config(|config| {
config.features.enable(Feature::Artifact);
})
.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.features.enable(Feature::Artifact);
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(())
}