1use libheif_rs::{
11 AuxiliaryImagesFilter, ColorProfile, ColorSpace, HeifContext, ImageHandle, LibHeif, RgbChroma,
12};
13
14pub struct DecodedHeic {
20 pub width: u32,
22 pub height: u32,
24 pub rgb: Vec<u16>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub enum HeicAuxKind {
32 Thumbnail,
34 DepthMap,
36 GainMap,
38 Auxiliary,
40}
41
42pub struct HeicAuxInfo {
44 pub kind: HeicAuxKind,
46 pub width: u32,
48 pub height: u32,
50 pub item_id: u32,
52 pub aux_type: Option<String>,
54}
55
56#[derive(Default)]
58pub struct HeicMetaBlobs {
59 pub exif: Option<Vec<u8>>,
62 pub xmp: Option<Vec<u8>>,
64 pub icc: Option<Vec<u8>>,
66 pub bit_depth: u8,
68 pub has_alpha: bool,
70 pub width: u32,
72 pub height: u32,
74}
75
76fn open_ctx(data: &[u8]) -> Result<HeifContext<'_>, String> {
81 HeifContext::read_from_bytes(data).map_err(|e| format!("failed to parse HEIC container: {e}"))
82}
83
84#[inline]
89fn upscale_to_u16(v: u16, depth: u8) -> u16 {
90 if depth >= 16 || depth == 0 {
91 return v;
92 }
93 let d = depth as u32;
94 let v = v as u32;
95 let scaled = if 2 * d >= 16 {
96 (v << (16 - d)) | (v >> (2 * d - 16))
97 } else {
98 v << (16 - d)
99 };
100 scaled as u16
101}
102
103fn handle_to_decoded(lib: &LibHeif, handle: &ImageHandle) -> Result<DecodedHeic, String> {
105 let bit_depth = handle.luma_bits_per_pixel().max(8);
106 let hdr = bit_depth > 8;
107 let chroma = if hdr {
108 RgbChroma::HdrRgbBe
109 } else {
110 RgbChroma::Rgb
111 };
112
113 let image = lib
117 .decode(handle, ColorSpace::Rgb(chroma), None)
118 .map_err(|e| format!("HEVC decode failed: {e}"))?;
119
120 let planes = image.planes();
121 let plane = planes
122 .interleaved
123 .ok_or_else(|| "decoded HEIC image has no interleaved RGB plane".to_string())?;
124
125 let w = plane.width as usize;
126 let h = plane.height as usize;
127 let stride = plane.stride;
128 let data = plane.data;
129 let mut rgb = vec![0u16; w * h * 3];
130
131 if hdr {
132 let depth = if plane.bits_per_pixel == 0 {
136 bit_depth
137 } else {
138 plane.bits_per_pixel
139 };
140 let mask: u16 = if depth >= 16 {
141 u16::MAX
142 } else {
143 (1u16 << depth) - 1
144 };
145 for y in 0..h {
146 let row_start = y * stride;
147 let row = data
148 .get(row_start..row_start + w * 6)
149 .ok_or_else(|| "HEIC HDR plane shorter than expected".to_string())?;
150 for x in 0..w {
151 let dst = (y * w + x) * 3;
152 for c in 0..3 {
153 let hi = row[x * 6 + c * 2] as u16;
154 let lo = row[x * 6 + c * 2 + 1] as u16;
155 let v = (((hi << 8) | lo) & mask).min(mask);
156 rgb[dst + c] = upscale_to_u16(v, depth);
157 }
158 }
159 }
160 } else {
161 for y in 0..h {
163 let row_start = y * stride;
164 let row = data
165 .get(row_start..row_start + w * 3)
166 .ok_or_else(|| "HEIC RGB plane shorter than expected".to_string())?;
167 for x in 0..w {
168 let dst = (y * w + x) * 3;
169 rgb[dst] = (row[x * 3] as u16) * 257;
170 rgb[dst + 1] = (row[x * 3 + 1] as u16) * 257;
171 rgb[dst + 2] = (row[x * 3 + 2] as u16) * 257;
172 }
173 }
174 }
175
176 Ok(DecodedHeic {
177 width: plane.width,
178 height: plane.height,
179 rgb,
180 })
181}
182
183pub fn decode_primary(data: &[u8]) -> Result<DecodedHeic, String> {
185 let ctx = open_ctx(data)?;
189 let lib = LibHeif::new();
190 let handle = ctx
191 .primary_image_handle()
192 .map_err(|e| format!("no primary image in HEIC file: {e}"))?;
193 handle_to_decoded(&lib, &handle)
194}
195
196fn classify_aux(aux_type: Option<&str>) -> HeicAuxKind {
198 match aux_type {
199 Some(t) => {
200 let l = t.to_ascii_lowercase();
201 if l.contains("gainmap") || l.contains("hdrgain") {
202 HeicAuxKind::GainMap
203 } else if l.contains("depth") || l.contains("disparity") {
204 HeicAuxKind::DepthMap
205 } else {
206 HeicAuxKind::Auxiliary
207 }
208 }
209 None => HeicAuxKind::Auxiliary,
210 }
211}
212
213pub fn list_aux_images(data: &[u8]) -> Result<Vec<HeicAuxInfo>, String> {
216 let ctx = open_ctx(data)?;
219 let handle = ctx
220 .primary_image_handle()
221 .map_err(|e| format!("no primary image in HEIC file: {e}"))?;
222 let mut out = Vec::new();
223
224 let n_thumbs = handle.number_of_thumbnails();
226 if n_thumbs > 0 {
227 let mut ids = vec![0u32; n_thumbs];
228 let got = handle.thumbnail_ids(&mut ids);
229 ids.truncate(got);
230 for id in ids {
231 if let Ok(th) = handle.thumbnail(id) {
232 out.push(HeicAuxInfo {
233 kind: HeicAuxKind::Thumbnail,
234 width: th.width(),
235 height: th.height(),
236 item_id: id,
237 aux_type: None,
238 });
239 }
240 }
241 }
242
243 let n_depth = handle.number_of_depth_images().max(0) as usize;
245 if n_depth > 0 {
246 let mut ids = vec![0u32; n_depth];
247 let got = handle.depth_image_ids(&mut ids);
248 ids.truncate(got);
249 for id in ids {
250 if let Ok(dh) = handle.depth_image_handle(id) {
251 out.push(HeicAuxInfo {
252 kind: HeicAuxKind::DepthMap,
253 width: dh.width(),
254 height: dh.height(),
255 item_id: id,
256 aux_type: None,
257 });
258 }
259 }
260 }
261
262 for aux in handle.auxiliary_images(AuxiliaryImagesFilter::new().omit_depth()) {
265 let aux_type = aux.auxiliary_type().ok().filter(|s| !s.is_empty());
266 out.push(HeicAuxInfo {
267 kind: classify_aux(aux_type.as_deref()),
268 width: aux.width(),
269 height: aux.height(),
270 item_id: aux.item_id(),
271 aux_type,
272 });
273 }
274
275 Ok(out)
276}
277
278pub fn decode_aux(data: &[u8], item_id: u32) -> Result<DecodedHeic, String> {
280 let ctx = open_ctx(data)?;
281 let lib = LibHeif::new();
282 let handle = ctx
283 .image_handle(item_id)
284 .map_err(|e| format!("HEIC auxiliary image {item_id} not found: {e}"))?;
285 handle_to_decoded(&lib, &handle)
286}
287
288fn strip_exif_prefix(raw: Vec<u8>) -> Option<Vec<u8>> {
292 if raw.len() < 4 {
293 return None;
294 }
295 let offset = u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as usize;
296 let start = 4usize.checked_add(offset)?;
297 if start >= raw.len() {
298 return None;
299 }
300 Some(raw[start..].to_vec())
301}
302
303pub fn extract_metadata_blobs(data: &[u8]) -> Result<HeicMetaBlobs, String> {
305 let ctx = open_ctx(data)?;
306 let handle = ctx
307 .primary_image_handle()
308 .map_err(|e| format!("no primary image in HEIC file: {e}"))?;
309
310 let mut blobs = HeicMetaBlobs {
311 bit_depth: handle.luma_bits_per_pixel().max(8),
312 has_alpha: handle.has_alpha_channel(),
313 width: handle.width(),
314 height: handle.height(),
315 ..Default::default()
316 };
317
318 for md in handle.all_metadata() {
319 if &md.item_type.0 == b"Exif" {
320 blobs.exif = strip_exif_prefix(md.raw_data);
321 } else if md.content_type == "application/rdf+xml" {
322 blobs.xmp = Some(md.raw_data);
323 }
324 }
325
326 if let Some(profile) = handle.color_profile_raw() {
327 let typ = profile.profile_type().0;
329 if &typ == b"rICC" || &typ == b"prof" {
330 blobs.icc = Some(profile.data);
331 }
332 }
333
334 Ok(blobs)
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn upscale_endpoints_are_exact() {
343 assert_eq!(upscale_to_u16(0, 8), 0);
345 assert_eq!(upscale_to_u16(255, 8), 65535);
346 assert_eq!(upscale_to_u16(0, 10), 0);
348 assert_eq!(upscale_to_u16(1023, 10), 65535);
349 assert_eq!(upscale_to_u16(4095, 12), 65535);
351 assert_eq!(upscale_to_u16(12345, 16), 12345);
353 }
354
355 #[test]
356 fn strip_exif_prefix_zero_offset() {
357 let raw = vec![0, 0, 0, 0, b'I', b'I', 0x2A, 0x00];
359 assert_eq!(strip_exif_prefix(raw), Some(vec![b'I', b'I', 0x2A, 0x00]));
360 }
361
362 #[test]
363 fn strip_exif_prefix_nonzero_offset() {
364 let raw = vec![0, 0, 0, 2, 0xAA, 0xBB, b'M', b'M'];
366 assert_eq!(strip_exif_prefix(raw), Some(vec![b'M', b'M']));
367 }
368
369 #[test]
370 fn strip_exif_prefix_rejects_short_or_oob() {
371 assert_eq!(strip_exif_prefix(vec![0, 0]), None);
372 assert_eq!(strip_exif_prefix(vec![0, 0, 0, 99, 1, 2]), None);
373 }
374
375 #[test]
376 fn classify_aux_recognises_urns() {
377 assert_eq!(
378 classify_aux(Some("urn:com:apple:photo:2020:aux:hdrgainmap")),
379 HeicAuxKind::GainMap
380 );
381 assert_eq!(
382 classify_aux(Some("urn:mpeg:hevc:2015:auxid:2:depth")),
383 HeicAuxKind::DepthMap
384 );
385 assert_eq!(
386 classify_aux(Some("urn:mpeg:hevc:2015:auxid:1")),
387 HeicAuxKind::Auxiliary
388 );
389 assert_eq!(classify_aux(None), HeicAuxKind::Auxiliary);
390 }
391
392 #[test]
393 fn decode_primary_rejects_junk() {
394 let junk = vec![0u8; 64];
395 assert!(decode_primary(&junk).is_err());
396 }
397
398 #[test]
399 fn extract_metadata_blobs_rejects_junk() {
400 let junk = vec![0u8; 64];
401 assert!(extract_metadata_blobs(&junk).is_err());
402 }
403
404 #[test]
407 fn decode_primary_homebrew_sample() {
408 let candidates = [
409 "/opt/homebrew/share/libheif/example.heic",
410 "/usr/local/share/libheif/example.heic",
411 ];
412 let Some(path) = candidates.iter().find(|p| std::path::Path::new(p).exists()) else {
413 eprintln!("skipping: no libheif sample HEIC found");
414 return;
415 };
416 let data = std::fs::read(path).expect("read sample heic");
417 let decoded = decode_primary(&data).expect("decode sample heic");
418 assert!(decoded.width > 0 && decoded.height > 0);
419 assert_eq!(
420 decoded.rgb.len(),
421 decoded.width as usize * decoded.height as usize * 3
422 );
423 }
424}