1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
//! Image loading and data management
use crate::error::{ImageError, Result};
use crate::source::ImageSource;
use base64::Engine;
use image::{DynamicImage, GenericImageView};
/// Decoded image data ready for GPU upload
#[derive(Debug, Clone)]
pub struct ImageData {
/// Raw RGBA pixel data
pixels: Vec<u8>,
/// Image width in pixels
width: u32,
/// Image height in pixels
height: u32,
}
impl ImageData {
/// Create ImageData from raw RGBA pixels
pub fn from_rgba(pixels: Vec<u8>, width: u32, height: u32) -> Result<Self> {
let expected_len = (width * height * 4) as usize;
if pixels.len() != expected_len {
return Err(ImageError::Decode(format!(
"Invalid pixel data length: expected {}, got {}",
expected_len,
pixels.len()
)));
}
Ok(Self {
pixels,
width,
height,
})
}
/// Load an image from a source (synchronous)
///
/// This method uses the platform asset loader when available (via the "platform" feature),
/// falling back to direct filesystem access on desktop.
///
/// Note: URL sources require the "network" feature and will fail
/// without it. Use `load_async` for URL sources.
pub fn load(source: ImageSource) -> Result<Self> {
match source {
ImageSource::File(path) => {
// Try platform asset loader first (works cross-platform)
#[cfg(feature = "platform")]
{
if let Some(loader) = blinc_platform::assets::global_asset_loader() {
let asset_path =
blinc_platform::AssetPath::from(path.to_string_lossy().to_string());
match loader.load(&asset_path) {
Ok(data) => return Self::from_bytes(&data),
Err(e) => {
tracing::debug!(
"Platform asset loader failed, trying filesystem: {}",
e
);
}
}
}
}
// Fallback to direct filesystem access (desktop only)
let data = std::fs::read(&path)
.map_err(|e| ImageError::FileLoad(format!("{}: {}", path.display(), e)))?;
Self::from_bytes(&data)
}
ImageSource::Base64(data) => Self::from_base64(&data),
ImageSource::Bytes { data, format: _ } => Self::from_bytes(&data),
ImageSource::Url(_url) => {
#[cfg(feature = "network")]
{
// For sync loading, we use blocking
Err(ImageError::Network(
"Use load_async for URL sources, or use blocking runtime".to_string(),
))
}
#[cfg(not(feature = "network"))]
{
Err(ImageError::Network(
"URL loading requires the 'network' feature".to_string(),
))
}
}
ImageSource::Emoji { emoji, size } => {
#[cfg(feature = "emoji")]
{
Self::load_emoji(&emoji, size)
}
#[cfg(not(feature = "emoji"))]
{
let _ = (emoji, size);
Err(ImageError::Decode(
"Emoji loading requires the 'emoji' feature".to_string(),
))
}
}
ImageSource::Rgba {
data,
width,
height,
} => Self::from_rgba(data, width, height),
}
}
/// Load an emoji character as an image
///
/// Uses the system emoji font to render the emoji as an RGBA image.
/// Uses the global shared font registry to avoid loading the 180MB emoji font multiple times.
#[cfg(feature = "emoji")]
fn load_emoji(emoji: &str, size: f32) -> Result<Self> {
use blinc_text::EmojiRenderer;
// EmojiRenderer::new() uses the global shared font registry
let mut renderer = EmojiRenderer::new();
let sprite = renderer
.render_string(emoji, size)
.map_err(|e| ImageError::Decode(format!("Failed to render emoji: {:?}", e)))?;
Self::from_rgba(sprite.data, sprite.width, sprite.height)
}
/// Load an image from a path using the platform asset loader
///
/// This is the preferred way to load images in cross-platform code.
/// On desktop, paths are filesystem paths. On Android, paths refer to
/// assets in the APK. On iOS, paths refer to app bundle resources.
#[cfg(feature = "platform")]
pub fn load_asset(path: impl Into<String>) -> Result<Self> {
let path_str = path.into();
let asset_path = blinc_platform::AssetPath::from(path_str.clone());
let loader = blinc_platform::assets::global_asset_loader()
.ok_or_else(|| ImageError::FileLoad("No asset loader configured".to_string()))?;
let data = loader
.load(&asset_path)
.map_err(|e| ImageError::FileLoad(format!("{}: {}", path_str, e)))?;
Self::from_bytes(&data)
}
/// Load an image from a source (asynchronous)
#[cfg(feature = "network")]
pub async fn load_async(source: ImageSource) -> Result<Self> {
match source {
ImageSource::Url(url) => {
let response = reqwest::get(&url)
.await
.map_err(|e| ImageError::Network(e.to_string()))?;
if !response.status().is_success() {
return Err(ImageError::Network(format!(
"HTTP error: {}",
response.status()
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| ImageError::Network(e.to_string()))?;
Self::from_bytes(&bytes)
}
// For non-URL sources, delegate to sync load
other => Self::load(other),
}
}
/// Decode image from raw bytes
pub fn from_bytes(data: &[u8]) -> Result<Self> {
let img = image::load_from_memory(data)?;
Self::from_dynamic_image(img)
}
/// Decode image from base64 string
///
/// Supports both plain base64 and data URIs like:
/// - `iVBORw0KGgo...` (plain base64)
/// - `data:image/png;base64,iVBORw0KGgo...` (data URI)
pub fn from_base64(data: &str) -> Result<Self> {
// Handle data URI format
let base64_data = if data.starts_with("data:") {
// Find the base64 marker
data.find(";base64,")
.map(|pos| &data[pos + 8..])
.ok_or_else(|| ImageError::Base64("Invalid data URI format".to_string()))?
} else {
data
};
// Decode base64
let bytes = base64::engine::general_purpose::STANDARD.decode(base64_data)?;
Self::from_bytes(&bytes)
}
/// Convert a DynamicImage to ImageData
fn from_dynamic_image(img: DynamicImage) -> Result<Self> {
let (width, height) = img.dimensions();
let rgba = img.to_rgba8();
let pixels = rgba.into_raw();
Ok(Self {
pixels,
width,
height,
})
}
/// Get the raw RGBA pixel data
pub fn pixels(&self) -> &[u8] {
&self.pixels
}
/// Get the image width
pub fn width(&self) -> u32 {
self.width
}
/// Get the image height
pub fn height(&self) -> u32 {
self.height
}
/// Get image dimensions as (width, height)
pub fn dimensions(&self) -> (u32, u32) {
(self.width, self.height)
}
/// Get the aspect ratio (width / height)
pub fn aspect_ratio(&self) -> f32 {
self.width as f32 / self.height as f32
}
/// Get the number of bytes in the pixel data
pub fn byte_len(&self) -> usize {
self.pixels.len()
}
/// Take ownership of the pixel data
pub fn into_pixels(self) -> Vec<u8> {
self.pixels
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_rgba() {
// Create a 2x2 red image
let pixels = vec![
255, 0, 0, 255, // Red
255, 0, 0, 255, // Red
255, 0, 0, 255, // Red
255, 0, 0, 255, // Red
];
let data = ImageData::from_rgba(pixels, 2, 2).unwrap();
assert_eq!(data.width(), 2);
assert_eq!(data.height(), 2);
assert_eq!(data.byte_len(), 16);
}
#[test]
fn test_invalid_rgba_length() {
let pixels = vec![255, 0, 0, 255]; // Only 1 pixel for 2x2
let result = ImageData::from_rgba(pixels, 2, 2);
assert!(result.is_err());
}
#[test]
fn test_base64_data_uri() {
// 1x1 red PNG as base64
let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
let result = ImageData::from_base64(data_uri);
assert!(result.is_ok());
let img = result.unwrap();
assert_eq!(img.width(), 1);
assert_eq!(img.height(), 1);
}
}