ailloli_ui_icon/lib.rs
1//! Deterministic, UI-independent validation and rasterization of application SVG icons.
2//!
3//! Application icons have stricter size, square-shape, external-resource, and
4//! visible-alpha requirements than the legacy arbitrary-SVG adapter. Successful
5//! rasters are square premultiplied RGBA8 buffers. The process performs no network
6//! or filesystem I/O through this crate.
7//!
8//! # Examples
9//!
10//! ```
11//! use ailloli_ui_core::AppIcon;
12//! use ailloli_ui_icon::{rasterize_app_icon, validate_app_icon};
13//! const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>"#;
14//! let icon = AppIcon::from_static_svg(SVG, "assets/icon.svg");
15//! assert_eq!(validate_app_icon(&icon).unwrap().width, 10.0);
16//! assert_eq!(rasterize_app_icon(&icon, 32).unwrap().rgba.len(), 32 * 32 * 4);
17//! ```
18
19use ailloli_ui_core::{AppIcon, SvgSource};
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex, OnceLock};
22
23/// Maximum accepted application-icon source length: 4 MiB (4,194,304 bytes).
24///
25/// The boundary itself is accepted; only larger sources return
26/// [`IconError::TooLarge`].
27///
28/// # Examples
29///
30/// ```
31/// use ailloli_ui_icon::MAX_SVG_BYTES;
32/// assert_eq!(MAX_SVG_BYTES, 4_194_304);
33/// ```
34pub const MAX_SVG_BYTES: usize = 4 * 1024 * 1024;
35/// Maximum accepted SVG view-box and parsed-tree dimension, in SVG/CSS pixels.
36///
37/// Exactly 8,192 is accepted; larger width or height is rejected.
38///
39/// # Examples
40///
41/// ```
42/// use ailloli_ui_icon::MAX_SVG_DIMENSION;
43/// assert_eq!(MAX_SVG_DIMENSION, 8_192.0);
44/// ```
45pub const MAX_SVG_DIMENSION: f32 = 8192.0;
46/// Maximum relative width/height delta treated as numerically square.
47///
48/// Validation computes `abs(width - height) / max(width, height)` in `f64` and
49/// rejects only values strictly greater than `1e-6`.
50///
51/// # Examples
52///
53/// ```
54/// use ailloli_ui_icon::SQUARE_RELATIVE_EPSILON;
55/// assert_eq!(SQUARE_RELATIVE_EPSILON, 0.000_001);
56/// ```
57pub const SQUARE_RELATIVE_EPSILON: f64 = 1.0e-6;
58
59/// Metadata produced after source, geometry, parse, and visible-alpha validation.
60///
61/// `width` and `height` are the parsed [`usvg`] tree size in SVG/CSS pixels,
62/// which may differ from raw view-box values due to root sizing rules. Public
63/// construction permits arbitrary strings, infinities, and NaNs; only values
64/// returned by [`validate_app_icon`] carry the validation guarantee.
65///
66/// # Examples
67///
68/// ```
69/// use ailloli_ui_core::AppIcon;
70/// use ailloli_ui_icon::validate_app_icon;
71/// const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><path d="M0 0h2v2H0z"/></svg>"#;
72/// let validated = validate_app_icon(&AppIcon::from_static_svg(SVG, "icon.svg")).unwrap();
73/// assert_eq!((validated.width, validated.height), (2.0, 2.0));
74/// assert_eq!(validated.sha256.len(), 64);
75/// ```
76#[derive(Debug, Clone, PartialEq)]
77pub struct ValidatedIcon {
78 /// Lowercase 64-character SHA-256 digest of the exact source bytes.
79 pub sha256: String,
80 /// Parsed tree width in SVG/CSS pixels.
81 pub width: f32,
82 /// Parsed tree height in SVG/CSS pixels.
83 pub height: f32,
84}
85
86/// Square premultiplied RGBA8 raster output.
87///
88/// Pixels are row-major, four bytes per pixel in red, green, blue, alpha order.
89/// Constructor-produced length is `width * height * 4`, and both dimensions are
90/// equal and at least one. Public fields can bypass all of those invariants.
91/// Cloning shares the allocation through [`Arc`].
92///
93/// # Examples
94///
95/// ```
96/// use ailloli_ui_core::AppIcon;
97/// use ailloli_ui_icon::rasterize_app_icon;
98/// const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1" fill="red"/></svg>"#;
99/// let raster = rasterize_app_icon(&AppIcon::from_static_svg(SVG, "icon.svg"), 8).unwrap();
100/// assert_eq!((raster.width, raster.height, raster.rgba.len()), (8, 8, 256));
101/// ```
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct RasterizedIcon {
104 /// Raster width in pixels; constructor-produced values are `px.max(1)`.
105 pub width: u32,
106 /// Raster height in pixels, equal to [`Self::width`] for produced values.
107 pub height: u32,
108 /// Shared row-major premultiplied RGBA8 bytes.
109 pub rgba: Arc<[u8]>,
110}
111
112/// Application-icon validation and raster allocation failures.
113///
114/// XML/SVG/backend messages and rejected resource strings are included verbatim
115/// and can vary with dependency versions. Match variants for control flow.
116///
117/// # Examples
118///
119/// ```
120/// use ailloli_ui_icon::IconError;
121/// assert_eq!(IconError::MissingViewBox.to_string(), "application icon SVG must declare a positive viewBox");
122/// ```
123#[derive(Debug, thiserror::Error)]
124pub enum IconError {
125 /// Source length is strictly greater than [`MAX_SVG_BYTES`].
126 #[error("SVG source exceeds the 4 MiB application icon limit")]
127 TooLarge,
128 /// Source is not UTF-8, is malformed XML, or its root is not `<svg>`.
129 #[error("application icon SVG is not valid UTF-8/XML: {0}")]
130 InvalidXml(String),
131 /// View box is absent/malformed, not four numbers, or has nonpositive size.
132 #[error("application icon SVG must declare a positive viewBox")]
133 MissingViewBox,
134 /// Relative view-box width/height delta exceeds square tolerance.
135 #[error("application icon SVG viewBox must be square (got {0}x{1})")]
136 NonSquare(f64, f64),
137 /// A forbidden element, reference, or recognized external CSS value was found.
138 #[error("application icon SVG contains forbidden element or external resource `{0}`")]
139 ExternalResource(String),
140 /// Raw view box or parsed tree width/height exceeds 8,192 pixels.
141 #[error("application icon SVG dimensions exceed {MAX_SVG_DIMENSION}px")]
142 DimensionsTooLarge,
143 /// [`usvg`] rejected otherwise structurally prevalidated bytes.
144 #[error("application icon SVG could not be parsed: {0}")]
145 InvalidSvg(String),
146 /// A 64-by-64 validation preview contained no pixel with nonzero alpha.
147 #[error("application icon SVG rendered fully transparent")]
148 FullyTransparent,
149 /// Square RGBA pixmap allocation failed for the normalized side length.
150 #[error("failed to allocate a {0}px application icon raster")]
151 Allocation(u32),
152}
153
154/// Parses positive view-box dimensions and rejects recognized active resources.
155///
156/// XML element matching is exact (`script`, `image`, `foreignObject`). Nonempty
157/// `href`/`xlink:href` values must start with `#`. Every attribute is also scanned
158/// case-insensitively for exact substrings `url(http:`, `url(https:`, `url(file:`,
159/// or `@import`. This is a deliberately specific syntactic screen, followed by
160/// `usvg` parsing; it is not a general sanitizer for arbitrary SVG consumers.
161///
162/// # Errors
163///
164/// Returns [`IconError::InvalidXml`] for non-UTF-8, malformed XML, or a non-SVG
165/// root; [`IconError::ExternalResource`] for a recognized active element or
166/// external reference; and [`IconError::MissingViewBox`] when the view box is
167/// absent, malformed, not four values, or has a non-positive extent.
168fn parse_view_box(bytes: &[u8]) -> Result<(f64, f64), IconError> {
169 let text = std::str::from_utf8(bytes).map_err(|err| IconError::InvalidXml(err.to_string()))?;
170 let doc =
171 roxmltree::Document::parse(text).map_err(|err| IconError::InvalidXml(err.to_string()))?;
172 let root = doc.root_element();
173 if root.tag_name().name() != "svg" {
174 return Err(IconError::InvalidXml(
175 "root element is not <svg>".to_string(),
176 ));
177 }
178 for node in doc.descendants().filter(|node| node.is_element()) {
179 let name = node.tag_name().name();
180 if matches!(name, "script" | "image" | "foreignObject") {
181 return Err(IconError::ExternalResource(name.to_string()));
182 }
183 for attribute in node.attributes() {
184 let value = attribute.value().trim();
185 let lower = value.to_ascii_lowercase();
186 if matches!(attribute.name(), "href" | "xlink:href")
187 && !value.is_empty()
188 && !value.starts_with('#')
189 {
190 return Err(IconError::ExternalResource(value.to_string()));
191 }
192 if lower.contains("url(http:")
193 || lower.contains("url(https:")
194 || lower.contains("url(file:")
195 || lower.contains("@import")
196 {
197 return Err(IconError::ExternalResource(value.to_string()));
198 }
199 }
200 }
201 let values: Vec<f64> = root
202 .attribute("viewBox")
203 .or_else(|| root.attribute("viewbox"))
204 .ok_or(IconError::MissingViewBox)?
205 .split(|ch: char| ch.is_ascii_whitespace() || ch == ',')
206 .filter(|value| !value.is_empty())
207 .map(|value| value.parse::<f64>().map_err(|_| IconError::MissingViewBox))
208 .collect::<Result<_, _>>()?;
209 if values.len() != 4 || values[2] <= 0.0 || values[3] <= 0.0 {
210 return Err(IconError::MissingViewBox);
211 }
212 Ok((values[2], values[3]))
213}
214
215/// Validates source size, XML/resource structure, square geometry, and visibility.
216///
217/// Validation accepts at most 4 MiB, requires an exact `viewBox` or `viewbox`
218/// attribute with four comma/ASCII-whitespace-separated `f64` values and positive
219/// width/height, enforces relative squareness and both raw/parsed 8,192-pixel
220/// limits, parses through [`usvg`], then requires nonzero alpha in a 64-pixel
221/// preview. View-box origins may be any parseable numbers. The returned digest
222/// covers exact source bytes; the `AppIcon` source-path label is irrelevant.
223///
224/// # Errors
225///
226/// Returns the corresponding [`IconError`] at the first failed stage, including
227/// preview allocation. Error strings can embed parser text or rejected attributes.
228///
229/// # Examples
230///
231/// ```
232/// use ailloli_ui_core::AppIcon;
233/// use ailloli_ui_icon::{validate_app_icon, IconError};
234/// const GOOD: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4"><circle cx="2" cy="2" r="2"/></svg>"#;
235/// let validated = validate_app_icon(&AppIcon::from_static_svg(GOOD, "good.svg")).unwrap();
236/// assert_eq!((validated.width, validated.height), (4.0, 4.0));
237/// const BAD: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 2"><rect width="4" height="2"/></svg>"#;
238/// assert!(matches!(
239/// validate_app_icon(&AppIcon::from_static_svg(BAD, "bad.svg")),
240/// Err(IconError::NonSquare(width, height)) if width == 4.0 && height == 2.0
241/// ));
242/// ```
243pub fn validate_app_icon(icon: &AppIcon) -> Result<ValidatedIcon, IconError> {
244 if icon.bytes().len() > MAX_SVG_BYTES {
245 return Err(IconError::TooLarge);
246 }
247 let (view_width, view_height) = parse_view_box(icon.bytes())?;
248 let relative_delta = (view_width - view_height).abs() / view_width.max(view_height);
249 if relative_delta > SQUARE_RELATIVE_EPSILON {
250 return Err(IconError::NonSquare(view_width, view_height));
251 }
252 if view_width > MAX_SVG_DIMENSION as f64 || view_height > MAX_SVG_DIMENSION as f64 {
253 return Err(IconError::DimensionsTooLarge);
254 }
255 let tree = usvg::Tree::from_data(icon.bytes(), &usvg::Options::default())
256 .map_err(|err| IconError::InvalidSvg(err.to_string()))?;
257 let size = tree.size();
258 if size.width() > MAX_SVG_DIMENSION || size.height() > MAX_SVG_DIMENSION {
259 return Err(IconError::DimensionsTooLarge);
260 }
261 let preview = rasterize_tree(&tree, 64)?;
262 if !preview.rgba.chunks_exact(4).any(|pixel| pixel[3] != 0) {
263 return Err(IconError::FullyTransparent);
264 }
265 Ok(ValidatedIcon {
266 sha256: icon.sha256(),
267 width: size.width(),
268 height: size.height(),
269 })
270}
271
272/// Uniformly scales and centers a parsed tree into a transparent square pixmap.
273///
274/// `px` clamps to one. Aspect ratio is preserved and unused space stays
275/// transparent. Output uses tiny-skia's premultiplied RGBA byte order. This
276/// helper does not check source policy, squareness, or visible alpha.
277///
278/// # Errors
279///
280/// Returns [`IconError::Allocation`] when tiny-skia cannot allocate the clamped
281/// square pixmap.
282fn rasterize_tree(tree: &usvg::Tree, px: u32) -> Result<RasterizedIcon, IconError> {
283 let px = px.max(1);
284 let mut pixmap = tiny_skia::Pixmap::new(px, px).ok_or(IconError::Allocation(px))?;
285 let size = tree.size();
286 let scale = px as f32 / size.width().max(size.height());
287 let offset_x = (px as f32 - size.width() * scale) * 0.5;
288 let offset_y = (px as f32 - size.height() * scale) * 0.5;
289 let transform =
290 tiny_skia::Transform::from_translate(offset_x, offset_y).post_scale(scale, scale);
291 resvg::render(tree, transform, &mut pixmap.as_mut());
292 Ok(RasterizedIcon {
293 width: px,
294 height: px,
295 rgba: Arc::from(pixmap.data().to_vec()),
296 })
297}
298
299/// Process-global, lazily initialized, unbounded digest-and-size raster cache.
300static RASTER_CACHE: OnceLock<Mutex<HashMap<(String, u32), RasterizedIcon>>> = OnceLock::new();
301
302/// Validates and rasterizes an app icon, caching by source digest and side length.
303///
304/// `px` clamps to one for both key and output. Validation (including a 64-pixel
305/// preview) runs before every cache lookup. On a miss, source bytes are parsed a
306/// second time for final rendering. Sequential cache hits clone the
307/// [`RasterizedIcon`] and share its pixel [`Arc`]; concurrent misses can render
308/// redundantly and return different allocations before one cached value wins.
309/// The process-global cache has no eviction or byte/count bound.
310///
311/// # Errors
312///
313/// Returns any [`validate_app_icon`] error, a second-stage [`IconError::InvalidSvg`],
314/// or [`IconError::Allocation`] for the requested normalized raster size.
315///
316/// # Panics
317///
318/// Panics with `"app icon cache poisoned"` if another thread poisons the global
319/// cache mutex. Allocation failures may also abort rather than return depending
320/// on the allocator.
321///
322/// # Examples
323///
324/// ```
325/// use std::sync::Arc;
326/// use ailloli_ui_core::AppIcon;
327/// use ailloli_ui_icon::rasterize_app_icon;
328/// const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>"#;
329/// let icon = AppIcon::from_static_svg(SVG, "icon.svg");
330/// let first = rasterize_app_icon(&icon, 0).unwrap();
331/// let second = rasterize_app_icon(&icon, 1).unwrap();
332/// assert_eq!((first.width, first.height), (1, 1));
333/// assert!(Arc::ptr_eq(&first.rgba, &second.rgba));
334/// ```
335pub fn rasterize_app_icon(icon: &AppIcon, px: u32) -> Result<RasterizedIcon, IconError> {
336 let validated = validate_app_icon(icon)?;
337 let key = (validated.sha256, px.max(1));
338 let cache = RASTER_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
339 if let Some(raster) = cache
340 .lock()
341 .expect("app icon cache poisoned")
342 .get(&key)
343 .cloned()
344 {
345 return Ok(raster);
346 }
347 let tree = usvg::Tree::from_data(icon.bytes(), &usvg::Options::default())
348 .map_err(|err| IconError::InvalidSvg(err.to_string()))?;
349 let raster = rasterize_tree(&tree, key.1)?;
350 cache
351 .lock()
352 .expect("app icon cache poisoned")
353 .insert(key, raster.clone());
354 Ok(raster)
355}
356
357/// Rasterizes an arbitrary legacy SVG source without application-icon validation.
358///
359/// This adapter bypasses source-byte, resource-syntax, square-shape, dimension,
360/// visible-alpha, and cache checks. It uses default [`usvg`] parsing and returns
361/// a newly allocated, square, premultiplied RGBA8 vector; `px == 0` becomes one.
362/// Invalid SVG or raster allocation failure returns `None` with no diagnostic.
363/// Consumers requiring the application-icon security/portability policy must use
364/// [`rasterize_app_icon`] instead.
365///
366/// # Examples
367///
368/// ```
369/// use ailloli_ui_core::SvgSource;
370/// use ailloli_ui_icon::rasterize_svg_source;
371/// let source = SvgSource::Static(br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 1"><rect width="2" height="1"/></svg>"#);
372/// assert_eq!(rasterize_svg_source(&source, 3).unwrap().len(), 3 * 3 * 4);
373/// assert!(rasterize_svg_source(&SvgSource::Static(b"not svg"), 3).is_none());
374/// ```
375pub fn rasterize_svg_source(source: &SvgSource, px: u32) -> Option<Vec<u8>> {
376 let tree = usvg::Tree::from_data(source.as_bytes(), &usvg::Options::default()).ok()?;
377 rasterize_tree(&tree, px)
378 .ok()
379 .map(|raster| raster.rgba.to_vec())
380}
381
382#[cfg(test)]
383/// Unit tests for validation, tolerance, security filtering, visibility, and cache sharing.
384mod tests {
385 use super::*;
386
387 /// Wraps static fixture bytes with the conventional test source-path label.
388 fn icon(svg: &'static [u8]) -> AppIcon {
389 AppIcon::from_static_svg(svg, "src/assets/icons/icon.svg")
390 }
391
392 #[test]
393 fn validates_and_upscales_visible_square_svg() {
394 let source = icon(br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="#ff0000"/></svg>"##);
395 let validated = validate_app_icon(&source).unwrap();
396 assert_eq!((validated.width, validated.height), (10.0, 10.0));
397 let raster = rasterize_app_icon(&source, 256).unwrap();
398 assert_eq!(raster.rgba.len(), 256 * 256 * 4);
399 assert!(raster
400 .rgba
401 .chunks_exact(4)
402 .any(|pixel| pixel[0] > 200 && pixel[3] > 0));
403 }
404
405 #[test]
406 fn permits_only_numerical_square_tolerance() {
407 let source = icon(br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 79.375001 79.374999"><rect width="100%" height="100%"/></svg>"#);
408 validate_app_icon(&source).unwrap();
409 let source = icon(br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 99"><rect width="100" height="99"/></svg>"#);
410 assert!(matches!(
411 validate_app_icon(&source),
412 Err(IconError::NonSquare(..))
413 ));
414 }
415
416 #[test]
417 fn rejects_active_external_and_transparent_sources() {
418 for svg in [
419 br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><script/></svg>"#.as_slice(),
420 br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="https://example.test/a.png"/></svg>"#.as_slice(),
421 br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>"#.as_slice(),
422 ] {
423 assert!(validate_app_icon(&icon(svg)).is_err());
424 }
425 }
426
427 #[test]
428 fn raster_cache_reuses_the_same_pixel_allocation() {
429 let source = icon(br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>"#);
430 let first = rasterize_app_icon(&source, 32).unwrap();
431 let second = rasterize_app_icon(&source, 32).unwrap();
432 assert!(Arc::ptr_eq(&first.rgba, &second.rgba));
433 }
434}