oxiui_render_soft/canvas_upload.rs
1//! Canvas 2D pixel upload path — write a [`Framebuffer`] to an HTML canvas element.
2//!
3//! This module provides a `putImageData`-based upload path as an alternative
4//! to WebGPU for environments that support Canvas 2D but not WebGPU (older
5//! browsers, embedded environments, certain CI configurations).
6//!
7//! # Platform notes
8//!
9//! - **wasm32**: the `upload_framebuffer` and `upload_rgba` functions are fully
10//! implemented using `web_sys::CanvasRenderingContext2d` and `ImageData`.
11//! - **native**: both functions are no-ops that always return `Ok(())`.
12//! This enables `--all-features` native builds to compile successfully.
13//!
14//! # Feature gate
15//!
16//! Enable the `canvas-2d` Cargo feature to activate this module. Without that
17//! feature, the module is still compiled but contains only the native stubs.
18//!
19//! # Example (wasm32, `canvas-2d` feature)
20//!
21//! ```rust,ignore
22//! use oxiui_render_soft::{Framebuffer, canvas_upload::upload_framebuffer};
23//! use oxiui_core::Color;
24//!
25//! let fb = Framebuffer::with_fill(320, 240, Color(30, 30, 30, 255));
26//! upload_framebuffer(&fb, "my-canvas").expect("canvas upload failed");
27//! ```
28
29use crate::framebuffer::Framebuffer;
30
31// ---------------------------------------------------------------------------
32// wasm32 implementation
33// ---------------------------------------------------------------------------
34
35/// Upload the pixel contents of `fb` to the HTML canvas element with the
36/// given `canvas_id` using `CanvasRenderingContext2d.putImageData`.
37///
38/// The canvas is resized to `fb.width() × fb.height()` before the upload.
39///
40/// # Errors
41///
42/// Returns a descriptive `String` error if:
43/// * The `canvas_id` element is not found in the DOM.
44/// * The element is not an `<canvas>` element.
45/// * The 2D rendering context cannot be obtained.
46/// * `ImageData` construction fails (browser restriction or out-of-memory).
47#[cfg(all(feature = "canvas-2d", target_arch = "wasm32"))]
48pub fn upload_framebuffer(fb: &Framebuffer, canvas_id: &str) -> Result<(), String> {
49 let rgba_buf = framebuffer_to_rgba8(fb);
50 upload_rgba(&rgba_buf, fb.width(), fb.height(), canvas_id)
51}
52
53/// Upload a raw RGBA8 byte slice to the given canvas element.
54///
55/// `data` must be exactly `width * height * 4` bytes in RGBA row-major order.
56///
57/// # Errors
58///
59/// Returns a descriptive error string on failure (see [`upload_framebuffer`]).
60#[cfg(all(feature = "canvas-2d", target_arch = "wasm32"))]
61pub fn upload_rgba(data: &[u8], width: u32, height: u32, canvas_id: &str) -> Result<(), String> {
62 use wasm_bindgen::JsCast;
63
64 let window =
65 web_sys::window().ok_or_else(|| "canvas_upload: no global window object".to_owned())?;
66 let document = window
67 .document()
68 .ok_or_else(|| "canvas_upload: window.document is None".to_owned())?;
69
70 // Locate the canvas element.
71 let element = document
72 .get_element_by_id(canvas_id)
73 .ok_or_else(|| format!("canvas_upload: element '{canvas_id}' not found"))?;
74 let canvas: web_sys::HtmlCanvasElement = element
75 .dyn_into::<web_sys::HtmlCanvasElement>()
76 .map_err(|_| format!("canvas_upload: '{canvas_id}' is not an <canvas>"))?;
77
78 // Resize the canvas to match the framebuffer.
79 canvas.set_width(width);
80 canvas.set_height(height);
81
82 // Obtain 2D context.
83 let ctx = canvas
84 .get_context("2d")
85 .map_err(|e| format!("canvas_upload: get_context('2d') failed: {e:?}"))?
86 .ok_or_else(|| format!("canvas_upload: '2d' context not available on '{canvas_id}'"))?
87 .dyn_into::<web_sys::CanvasRenderingContext2d>()
88 .map_err(|_| "canvas_upload: context is not CanvasRenderingContext2d".to_owned())?;
89
90 // Build ImageData from the RGBA8 byte slice.
91 // `ImageData::new_with_u8_clamped_array_and_sh` creates an ImageData from a
92 // `Uint8ClampedArray`. The slice is copied into Wasm linear memory via
93 // `js_sys::Uint8ClampedArray::view` (zero-copy borrow).
94 //
95 // Safety: `view` is sound here because we do not invoke any JS that could
96 // cause the Wasm heap to grow between `view()` and `new_with_u8_clamped_array_and_sh()`.
97 let clamped_arr = js_sys::Uint8ClampedArray::from(data);
98 let image_data =
99 web_sys::ImageData::new_with_u8_clamped_array_and_sh(&clamped_arr, width, height)
100 .map_err(|e| format!("canvas_upload: ImageData construction failed: {e:?}"))?;
101
102 // Upload.
103 ctx.put_image_data(&image_data, 0.0, 0.0)
104 .map_err(|e| format!("canvas_upload: putImageData failed: {e:?}"))?;
105
106 Ok(())
107}
108
109// ---------------------------------------------------------------------------
110// Native stubs
111// ---------------------------------------------------------------------------
112
113/// Native-platform stub — always returns `Ok(())`.
114///
115/// On `wasm32` + `canvas-2d` the real implementation is active.
116#[cfg(not(all(feature = "canvas-2d", target_arch = "wasm32")))]
117pub fn upload_framebuffer(fb: &Framebuffer, canvas_id: &str) -> Result<(), String> {
118 // No canvas on native targets; discard the arguments explicitly.
119 let _ = (fb, canvas_id);
120 Ok(())
121}
122
123/// Native-platform stub — always returns `Ok(())`.
124///
125/// On `wasm32` + `canvas-2d` the real implementation is active.
126#[cfg(not(all(feature = "canvas-2d", target_arch = "wasm32")))]
127pub fn upload_rgba(data: &[u8], width: u32, height: u32, canvas_id: &str) -> Result<(), String> {
128 // No canvas on native targets; discard the arguments explicitly.
129 let _ = (data, width, height, canvas_id);
130 Ok(())
131}
132
133// ---------------------------------------------------------------------------
134// Shared helper
135// ---------------------------------------------------------------------------
136
137/// Convert a [`Framebuffer`] (`0xAARRGGBB`) to a flat RGBA8 byte vector
138/// (`[R, G, B, A, ...]`) as expected by `ImageData`.
139pub fn framebuffer_to_rgba8(fb: &Framebuffer) -> Vec<u8> {
140 use crate::framebuffer::unpack;
141 let mut out = Vec::with_capacity(fb.width() as usize * fb.height() as usize * 4);
142 for &px in fb.pixels() {
143 let (r, g, b, a) = unpack(px);
144 out.extend_from_slice(&[r, g, b, a]);
145 }
146 out
147}
148
149// ---------------------------------------------------------------------------
150// Tests
151// ---------------------------------------------------------------------------
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use oxiui_core::Color;
157
158 #[test]
159 fn framebuffer_to_rgba8_correct_layout() {
160 let fb = Framebuffer::with_fill(2, 1, Color(10, 20, 30, 200));
161 let raw = framebuffer_to_rgba8(&fb);
162 assert_eq!(raw.len(), 8);
163 // First pixel: R=10, G=20, B=30, A=200
164 assert_eq!(&raw[..4], &[10, 20, 30, 200]);
165 // Second pixel: same
166 assert_eq!(&raw[4..], &[10, 20, 30, 200]);
167 }
168
169 #[test]
170 fn framebuffer_to_rgba8_transparent() {
171 let fb = Framebuffer::new(1, 1); // transparent black
172 let raw = framebuffer_to_rgba8(&fb);
173 assert_eq!(raw, vec![0, 0, 0, 0]);
174 }
175
176 #[test]
177 fn upload_framebuffer_native_noop() {
178 // On native, both upload functions are no-ops and return Ok.
179 let fb = Framebuffer::with_fill(4, 4, Color(255, 0, 0, 255));
180 let result = upload_framebuffer(&fb, "test-canvas");
181 assert!(result.is_ok(), "native stub must return Ok: {result:?}");
182 }
183
184 #[test]
185 fn upload_rgba_native_noop() {
186 // 4×4 red RGBA: 16 pixels × 4 channels = 64 bytes
187 let data: Vec<u8> = (0..64)
188 .map(|i| match i % 4 {
189 0 | 3 => 255,
190 _ => 0,
191 })
192 .collect();
193 let result = upload_rgba(&data, 4, 4, "test-canvas");
194 assert!(result.is_ok(), "native stub must return Ok: {result:?}");
195 }
196}