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
//! GIF file format reader.
//!
//! Parses GIF87a/GIF89a files to extract comments, XMP, animation info.
//! Mirrors ExifTool's GIF.pm.
use crate::error::{Error, Result};
use crate::tag::{Tag, TagGroup, TagId};
use crate::value::Value;
pub fn read_gif(data: &[u8]) -> Result<Vec<Tag>> {
if data.len() < 13 || !data.starts_with(b"GIF8") {
return Err(Error::InvalidData("not a GIF file".into()));
}
let mut tags = Vec::new();
let version = String::from_utf8_lossy(&data[3..6]).to_string();
tags.push(mk("GIFVersion", "GIF Version", Value::String(version)));
// Logical Screen Descriptor (bytes 6-12)
let width = u16::from_le_bytes([data[6], data[7]]);
let height = u16::from_le_bytes([data[8], data[9]]);
let packed = data[10];
let has_gct = (packed & 0x80) != 0;
let color_resolution = ((packed >> 4) & 0x07) + 1;
let gct_size = if has_gct { 3 * (1 << ((packed & 0x07) + 1)) } else { 0 };
let bg_color = data[11];
let _aspect_ratio = data[12];
tags.push(mk("ImageWidth", "Image Width", Value::U16(width)));
tags.push(mk("ImageHeight", "Image Height", Value::U16(height)));
tags.push(mk("HasColorMap", "Has Color Map", Value::String(if has_gct { "Yes" } else { "No" }.into())));
tags.push(mk("ColorResolutionDepth", "Color Resolution Depth", Value::U8(color_resolution)));
tags.push(mk("BitsPerPixel", "Bits Per Pixel", Value::U8((packed & 0x07) + 1)));
tags.push(mk("BackgroundColor", "Background Color", Value::U8(bg_color)));
// PixelAspectRatio: 0 = square pixels (undef), otherwise (val+15)/64
let aspect_ratio = data[12];
if aspect_ratio != 0 {
let par = (aspect_ratio as f64 + 15.0) / 64.0;
tags.push(mk("PixelAspectRatio", "Pixel Aspect Ratio", Value::String(format!("{:.4}", par))));
} else {
tags.push(mk("PixelAspectRatio", "Pixel Aspect Ratio", Value::U8(1)));
}
let mut pos = 13 + gct_size as usize;
let mut frame_count: u32 = 0;
let mut total_duration: f64 = 0.0;
while pos < data.len() {
match data[pos] {
// Image Descriptor
0x2C => {
frame_count += 1;
if pos + 10 > data.len() {
break;
}
let local_packed = data[pos + 9];
let has_lct = (local_packed & 0x80) != 0;
let lct_size = if has_lct { 3 * (1 << ((local_packed & 0x07) + 1)) } else { 0 };
pos += 10 + lct_size;
// Skip LZW minimum code size
if pos >= data.len() {
break;
}
pos += 1;
// Skip sub-blocks
pos = skip_sub_blocks(data, pos);
}
// Extension
0x21 => {
if pos + 2 > data.len() {
break;
}
let label = data[pos + 1];
pos += 2;
match label {
// Comment Extension
0xFE => {
let (comment, new_pos) = read_sub_blocks(data, pos);
pos = new_pos;
if !comment.is_empty() {
let text = String::from_utf8_lossy(&comment).to_string();
// Normalize newlines to ".." to match ExifTool output format
let text = text.replace("\r\n", "..").replace('\n', "..").replace('\r', "..");
tags.push(mk("Comment", "Comment", Value::String(text)));
}
}
// Graphic Control Extension
0xF9 => {
if pos + 5 <= data.len() && data[pos] == 4 {
let delay = u16::from_le_bytes([data[pos + 2], data[pos + 3]]);
total_duration += delay as f64 / 100.0;
let transparent_flag = (data[pos + 1] & 0x01) != 0;
if transparent_flag {
let transparent_idx = data[pos + 4];
tags.push(mk(
"TransparentColor",
"Transparent Color Index",
Value::U8(transparent_idx),
));
}
}
pos = skip_sub_blocks(data, pos);
}
// Application Extension
0xFF => {
if pos + 12 <= data.len() && data[pos] == 11 {
let app_id = &data[pos + 1..pos + 12];
pos += 12;
if app_id == b"NETSCAPE2.0" || app_id == b"ANIMEXTS1.0" {
// Animation loop count
if pos + 4 <= data.len() && data[pos] == 3 && data[pos + 1] == 1 {
let loop_count =
u16::from_le_bytes([data[pos + 2], data[pos + 3]]);
tags.push(mk(
"AnimationIterations",
"Animation Iterations",
Value::U16(if loop_count == 0 {
u16::MAX
} else {
loop_count
}),
));
}
pos = skip_sub_blocks(data, pos);
} else if &app_id[..8] == b"XMP Data" {
// XMP metadata — uses IncludeLengthBytes=2:
// sub-block length bytes are part of the data stream
// The raw XMP starts with a length byte followed by XMP content
// We need to read sub-blocks but include the length bytes in the stream
let (xmp_data, new_pos) = read_sub_blocks_include_len(data, pos);
pos = new_pos;
if !xmp_data.is_empty() {
// Strip the 258-byte landing zone from the end
// The landing zone ends with "\x01\x00" (last sub-block of 1 byte = 0x00)
// Find the real end: last occurrence of "?xpacket end"
let xmp_slice = if let Some(end_pos) = find_xpacket_end(&xmp_data) {
&xmp_data[..end_pos]
} else {
&xmp_data
};
if let Ok(xmp_tags) =
crate::metadata::XmpReader::read(xmp_slice)
{
tags.extend(xmp_tags);
}
}
} else if &app_id[..8] == b"ICCRGBG1" {
// ICC Profile
let (icc_data, new_pos) = read_sub_blocks(data, pos);
pos = new_pos;
if !icc_data.is_empty() {
if let Ok(icc_tags) =
crate::formats::icc::read_icc(&icc_data)
{
tags.extend(icc_tags);
}
}
} else {
pos = skip_sub_blocks(data, pos);
}
} else {
pos = skip_sub_blocks(data, pos);
}
}
// Plain Text Extension or unknown
_ => {
pos = skip_sub_blocks(data, pos);
}
}
}
// Trailer
0x3B => break,
_ => {
pos += 1;
}
}
}
if frame_count > 1 {
tags.push(mk("FrameCount", "Frame Count", Value::U32(frame_count)));
}
if frame_count > 1 && total_duration > 0.0 {
tags.push(mk(
"Duration",
"Duration",
Value::String(format!("{:.2} s", total_duration)),
));
}
Ok(tags)
}
fn skip_sub_blocks(data: &[u8], mut pos: usize) -> usize {
while pos < data.len() {
let block_size = data[pos] as usize;
pos += 1;
if block_size == 0 {
break;
}
pos += block_size;
}
pos
}
fn read_sub_blocks(data: &[u8], mut pos: usize) -> (Vec<u8>, usize) {
let mut result = Vec::new();
while pos < data.len() {
let block_size = data[pos] as usize;
pos += 1;
if block_size == 0 {
break;
}
if pos + block_size <= data.len() {
result.extend_from_slice(&data[pos..pos + block_size]);
}
pos += block_size;
}
(result, pos)
}
/// Read sub-blocks and include the length bytes in the output (for XMP in GIF)
fn read_sub_blocks_include_len(data: &[u8], mut pos: usize) -> (Vec<u8>, usize) {
let mut result = Vec::new();
while pos < data.len() {
let block_size = data[pos] as usize;
result.push(data[pos]); // include length byte
pos += 1;
if block_size == 0 {
break;
}
if pos + block_size <= data.len() {
result.extend_from_slice(&data[pos..pos + block_size]);
}
pos += block_size;
}
(result, pos)
}
/// Find the end of the XMP xpacket (returns position after the closing "?>")
fn find_xpacket_end(data: &[u8]) -> Option<usize> {
// Search for "?xpacket end=" from near the end
let pattern = b"?xpacket end=";
let start = if data.len() > 512 { data.len() - 512 } else { 0 };
let search_range = &data[start..];
if let Some(rel_pos) = search_range.windows(pattern.len()).rposition(|w| w == pattern) {
let abs_pos = start + rel_pos;
// Find the closing "?>" after this position
if let Some(end_rel) = data[abs_pos..].windows(2).position(|w| w == b"?>") {
return Some(abs_pos + end_rel + 2);
}
}
None
}
fn mk(name: &str, description: &str, value: Value) -> Tag {
let print_value = value.to_display_string();
Tag {
id: TagId::Text(name.to_string()),
name: name.to_string(),
description: description.to_string(),
group: TagGroup {
family0: "GIF".into(),
family1: "GIF".into(),
family2: "Image".into(),
},
raw_value: value,
print_value,
priority: 0,
}
}