Skip to content

Commit a0c8bb7

Browse files
committed
feat(fmt): add sprintf and unpack modules for Perl-compatible formatting and binary unpacking
1 parent 955b0ea commit a0c8bb7

File tree

4 files changed

+969
-0
lines changed

4 files changed

+969
-0
lines changed

src/fmt/mod.rs

Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
/// Format helper functions for sprintf-style operations
2+
///
3+
/// This module provides utilities for handling Perl sprintf patterns,
4+
/// particularly those involving split operations that produce variable
5+
/// numbers of arguments.
6+
7+
pub mod sprintf;
8+
pub mod unpack;
9+
10+
pub use sprintf::sprintf_perl;
11+
pub use unpack::unpack_binary;
12+
13+
use crate::types::TagValue;
14+
15+
/// Format values from a split operation using a sprintf-style format string
16+
///
17+
/// This handles cases like `sprintf("%.3f x %.3f mm", split(" ",$val))`
18+
/// where split produces a variable number of values that need to be formatted.
19+
pub fn sprintf_split_values(format_str: &str, values: &[TagValue]) -> String {
20+
// Count the number of format specifiers in the format string
21+
let format_specs = format_str.matches("%.3f").count()
22+
+ format_str.matches("%.2f").count()
23+
+ format_str.matches("%.1f").count()
24+
+ format_str.matches("%d").count()
25+
+ format_str.matches("%s").count()
26+
+ format_str.matches("%f").count();
27+
28+
// Convert values to strings based on the format specifiers
29+
let formatted_values: Vec<String> = values.iter()
30+
.take(format_specs)
31+
.map(|v| match v {
32+
TagValue::F64(f) => {
33+
// Check what precision is needed based on format string
34+
if format_str.contains("%.3f") {
35+
format!("{:.3}", f)
36+
} else if format_str.contains("%.2f") {
37+
format!("{:.2}", f)
38+
} else if format_str.contains("%.1f") {
39+
format!("{:.1}", f)
40+
} else {
41+
f.to_string()
42+
}
43+
}
44+
TagValue::I32(i) => i.to_string(),
45+
TagValue::String(s) => s.clone(),
46+
_ => v.to_string(),
47+
})
48+
.collect();
49+
50+
// Build the final formatted string
51+
match formatted_values.len() {
52+
0 => String::new(),
53+
1 if format_specs == 2 => {
54+
// Special case: one value but format expects two (like "%.3f x %.3f mm")
55+
// Use the same value twice
56+
apply_format(format_str, &[formatted_values[0].clone(), formatted_values[0].clone()])
57+
}
58+
_ => apply_format(format_str, &formatted_values)
59+
}
60+
}
61+
62+
/// Apply a format string with the given values
63+
fn apply_format(format_str: &str, values: &[String]) -> String {
64+
let mut result = format_str.to_string();
65+
let mut value_iter = values.iter();
66+
67+
// Replace format specifiers in order
68+
let patterns = ["%.3f", "%.2f", "%.1f", "%d", "%s", "%f"];
69+
70+
for pattern in &patterns {
71+
while result.contains(pattern) {
72+
if let Some(value) = value_iter.next() {
73+
result = result.replacen(pattern, value, 1);
74+
} else {
75+
// No more values, break
76+
break;
77+
}
78+
}
79+
}
80+
81+
result
82+
}
83+
84+
/// Handle sprintf with Perl string concatenation and repetition operations
85+
///
86+
/// This handles cases like `sprintf("%19d %4d %6d" . " %3d %4d %6d" x 8, split(" ",$val))`
87+
/// where the format string is built from concatenation and repetition before sprintf.
88+
///
89+
/// # Arguments
90+
/// * `base_format` - The base format string (e.g., "%19d %4d %6d")
91+
/// * `concat_part` - The part to concatenate and repeat (e.g., " %3d %4d %6d")
92+
/// * `repeat_count` - How many times to repeat the concat_part (e.g., 8)
93+
/// * `args` - The arguments to format with the completed format string (can be a single TagValue or array)
94+
///
95+
/// # Example
96+
/// ```
97+
/// # use exif_oxide::fmt::sprintf_with_string_concat_repeat;
98+
/// # use exif_oxide::types::TagValue;
99+
/// let result = sprintf_with_string_concat_repeat(
100+
/// "%19d %4d %6d",
101+
/// " %3d %4d %6d",
102+
/// 8,
103+
/// &TagValue::Array(vec![TagValue::I32(1), TagValue::I32(2), TagValue::I32(3)])
104+
/// );
105+
/// // Builds format: "%19d %4d %6d %3d %4d %6d %3d %4d %6d ..." (repeated 8 times)
106+
/// ```
107+
pub fn sprintf_with_string_concat_repeat(
108+
base_format: &str,
109+
concat_part: &str,
110+
repeat_count: usize,
111+
args: &TagValue
112+
) -> String {
113+
// Build complete format string: base + (concat_part repeated repeat_count times)
114+
let complete_format = format!("{}{}", base_format, concat_part.repeat(repeat_count));
115+
116+
// Convert TagValue to slice for sprintf_perl
117+
match args {
118+
TagValue::Array(arr) => sprintf_perl(&complete_format, arr),
119+
single_val => sprintf_perl(&complete_format, &[single_val.clone()]),
120+
}
121+
}
122+
123+
/// Safe division calculation following ExifTool pattern: $val ? numerator / $val : 0
124+
///
125+
/// This implements the common ExifTool pattern of computing a safe division
126+
/// while handling zero, empty, or undefined values by returning 0 instead of
127+
/// infinity or division-by-zero errors.
128+
///
129+
/// # Arguments
130+
/// * `numerator` - The numerator for the division (e.g., 1.0, 10.0)
131+
/// * `val` - The TagValue to use as denominator
132+
///
133+
/// # Returns
134+
/// * If val is truthy and non-zero: TagValue::F64(numerator / val)
135+
/// * If val is falsy or zero: TagValue::F64(0.0)
136+
pub fn safe_division(numerator: f64, val: &TagValue) -> TagValue {
137+
// Check if value is truthy (non-zero, non-empty) following ExifTool semantics
138+
let is_truthy = match val {
139+
TagValue::U8(v) => *v != 0,
140+
TagValue::U16(v) => *v != 0,
141+
TagValue::U32(v) => *v != 0,
142+
TagValue::U64(v) => *v != 0,
143+
TagValue::I16(v) => *v != 0,
144+
TagValue::I32(v) => *v != 0,
145+
TagValue::F64(v) => *v != 0.0 && !v.is_nan(),
146+
TagValue::String(s) => !s.is_empty() && s != "0",
147+
TagValue::Rational(num, _) => *num != 0,
148+
TagValue::SRational(num, _) => *num != 0,
149+
TagValue::Empty => false,
150+
_ => true, // Arrays, objects, etc. are considered truthy if they exist
151+
};
152+
153+
if !is_truthy {
154+
return TagValue::F64(0.0);
155+
}
156+
157+
// Extract numeric value and compute division
158+
match val {
159+
TagValue::U8(v) => TagValue::F64(numerator / (*v as f64)),
160+
TagValue::U16(v) => TagValue::F64(numerator / (*v as f64)),
161+
TagValue::U32(v) => TagValue::F64(numerator / (*v as f64)),
162+
TagValue::U64(v) => TagValue::F64(numerator / (*v as f64)),
163+
TagValue::I16(v) => TagValue::F64(numerator / (*v as f64)),
164+
TagValue::I32(v) => TagValue::F64(numerator / (*v as f64)),
165+
TagValue::F64(v) => TagValue::F64(numerator / v),
166+
TagValue::String(s) => {
167+
if let Ok(num) = s.parse::<f64>() {
168+
if num != 0.0 {
169+
TagValue::F64(numerator / num)
170+
} else {
171+
TagValue::F64(0.0)
172+
}
173+
} else {
174+
// Non-numeric string - return 0 following ExifTool semantics
175+
TagValue::F64(0.0)
176+
}
177+
}
178+
TagValue::Rational(num, denom) => {
179+
if *num != 0 && *denom != 0 {
180+
let val = *num as f64 / *denom as f64;
181+
TagValue::F64(numerator / val)
182+
} else {
183+
TagValue::F64(0.0)
184+
}
185+
}
186+
TagValue::SRational(num, denom) => {
187+
if *num != 0 && *denom != 0 {
188+
let val = *num as f64 / *denom as f64;
189+
TagValue::F64(numerator / val)
190+
} else {
191+
TagValue::F64(0.0)
192+
}
193+
}
194+
_ => TagValue::F64(0.0), // For complex types, return 0
195+
}
196+
}
197+
198+
/// Safe reciprocal calculation following ExifTool pattern: $val ? 1 / $val : 0
199+
///
200+
/// This is a convenience wrapper around `safe_division(1.0, val)`.
201+
///
202+
/// Common use cases:
203+
/// - Converting focal length to optical power (diopters)
204+
/// - Converting f-number to relative aperture calculations
205+
/// - Any reciprocal calculation where 0 input should yield 0 output
206+
///
207+
/// # Arguments
208+
/// * `val` - The TagValue to take the reciprocal of
209+
///
210+
/// # Returns
211+
/// * If val is truthy and non-zero: TagValue::F64(1.0 / val)
212+
/// * If val is falsy or zero: TagValue::F64(0.0)
213+
///
214+
/// # Example
215+
/// ```rust
216+
/// # use exif_oxide::fmt::safe_reciprocal;
217+
/// # use exif_oxide::types::TagValue;
218+
///
219+
/// // Normal case: 1/2 = 0.5
220+
/// assert_eq!(safe_reciprocal(&TagValue::I32(2)), TagValue::F64(0.5));
221+
///
222+
/// // Zero case: avoid division by zero
223+
/// assert_eq!(safe_reciprocal(&TagValue::I32(0)), TagValue::F64(0.0));
224+
///
225+
/// // Empty string case: treat as zero
226+
/// assert_eq!(safe_reciprocal(&TagValue::String("".to_string())), TagValue::F64(0.0));
227+
/// ```
228+
pub fn safe_reciprocal(val: &TagValue) -> TagValue {
229+
safe_division(1.0, val)
230+
}
231+
232+
/// Pack "C*" with bit extraction pattern
233+
///
234+
/// Implements: pack "C*", map { (($val>>$_)&mask)+offset } shifts...
235+
/// This extracts specific bit ranges from val at different shift positions,
236+
/// applies a mask and offset, then packs as unsigned chars into a binary string.
237+
///
238+
/// # Arguments
239+
/// * `val` - The TagValue to extract bits from
240+
/// * `shifts` - Array of bit shift positions
241+
/// * `mask` - Bitmask to apply after shifting
242+
/// * `offset` - Offset to add to each extracted value
243+
///
244+
/// # Example
245+
/// ```rust
246+
/// # use exif_oxide::fmt::pack_c_star_bit_extract;
247+
/// # use exif_oxide::types::TagValue;
248+
/// // Equivalent to: pack "C*", map { (($val>>$_)&0x1f)+0x60 } 10, 5, 0
249+
/// let result = pack_c_star_bit_extract(&TagValue::I32(0x1234), &[10, 5, 0], 0x1f, 0x60);
250+
/// ```
251+
pub fn pack_c_star_bit_extract(val: &TagValue, shifts: &[i32], mask: i32, offset: i32) -> TagValue {
252+
// Extract numeric value from TagValue
253+
let numeric_val = match val {
254+
TagValue::I32(i) => *i,
255+
TagValue::F64(f) => *f as i32,
256+
TagValue::String(s) => s.parse::<i32>().unwrap_or(0),
257+
_ => 0,
258+
};
259+
260+
let bytes: Vec<u8> = shifts.iter()
261+
.map(|&shift| (((numeric_val >> shift) & mask) + offset) as u8)
262+
.collect();
263+
264+
TagValue::String(String::from_utf8_lossy(&bytes).to_string())
265+
}
266+
267+
#[cfg(test)]
268+
mod tests;
269+
270+
#[cfg(test)]
271+
mod basic_tests {
272+
use super::*;
273+
274+
#[test]
275+
fn test_sprintf_split_single_value() {
276+
let values = vec![TagValue::F64(1.234)];
277+
let result = sprintf_split_values("%.3f x %.3f mm", &values);
278+
assert_eq!(result, "1.234 x 1.234 mm");
279+
}
280+
281+
#[test]
282+
fn test_sprintf_split_two_values() {
283+
let values = vec![TagValue::F64(1.234), TagValue::F64(5.678)];
284+
let result = sprintf_split_values("%.3f x %.3f mm", &values);
285+
assert_eq!(result, "1.234 x 5.678 mm");
286+
}
287+
288+
#[test]
289+
fn test_sprintf_split_string_values() {
290+
let values = vec![
291+
TagValue::String("10.5".to_string()),
292+
TagValue::String("20.3".to_string())
293+
];
294+
let result = sprintf_split_values("%.3f x %.3f mm", &values);
295+
assert_eq!(result, "10.5 x 20.3 mm");
296+
}
297+
298+
#[test]
299+
fn test_sprintf_with_string_concat_repeat() {
300+
// Test the specific failing case: sprintf("%19d %4d %6d" . " %3d %4d %6d" x 8, ...)
301+
let values = TagValue::Array(vec![
302+
TagValue::I32(1), TagValue::I32(2), TagValue::I32(3), // First 3 values
303+
TagValue::I32(4), TagValue::I32(5), TagValue::I32(6), // Repeated pattern
304+
TagValue::I32(7), TagValue::I32(8), TagValue::I32(9), // More values...
305+
]);
306+
307+
let result = sprintf_with_string_concat_repeat(
308+
"%19d %4d %6d",
309+
" %3d %4d %6d",
310+
2, // Test with 2 repetitions for simplicity
311+
&values
312+
);
313+
314+
// Should build format: "%19d %4d %6d %3d %4d %6d %3d %4d %6d"
315+
// Expected format string has: 19-width, 4-width, 6-width, then repeated 3-width, 4-width, 6-width pattern
316+
assert!(result.contains("1")); // First value
317+
assert!(result.contains("2")); // Second value
318+
assert!(result.contains("3")); // Third value
319+
}
320+
321+
#[test]
322+
fn test_sprintf_concat_repeat_empty_args() {
323+
// Test edge case with empty args
324+
let empty_args = TagValue::Array(vec![]);
325+
let result = sprintf_with_string_concat_repeat(
326+
"%d",
327+
" %d",
328+
3,
329+
&empty_args
330+
);
331+
// Should handle gracefully even with no args
332+
assert!(!result.is_empty()); // sprintf_perl should handle missing args gracefully
333+
}
334+
335+
#[test]
336+
fn test_safe_reciprocal() {
337+
// Normal cases: 1/val
338+
assert_eq!(safe_reciprocal(&TagValue::I32(2)), TagValue::F64(0.5));
339+
assert_eq!(safe_reciprocal(&TagValue::F64(4.0)), TagValue::F64(0.25));
340+
assert_eq!(safe_reciprocal(&TagValue::U16(10)), TagValue::F64(0.1));
341+
342+
// Zero cases: should return 0, not infinity
343+
assert_eq!(safe_reciprocal(&TagValue::I32(0)), TagValue::F64(0.0));
344+
assert_eq!(safe_reciprocal(&TagValue::F64(0.0)), TagValue::F64(0.0));
345+
assert_eq!(safe_reciprocal(&TagValue::String("0".to_string())), TagValue::F64(0.0));
346+
347+
// Empty/falsy cases: should return 0
348+
assert_eq!(safe_reciprocal(&TagValue::String("".to_string())), TagValue::F64(0.0));
349+
assert_eq!(safe_reciprocal(&TagValue::Empty), TagValue::F64(0.0));
350+
351+
// String numeric conversion
352+
assert_eq!(safe_reciprocal(&TagValue::String("2.5".to_string())), TagValue::F64(0.4));
353+
assert_eq!(safe_reciprocal(&TagValue::String("non-numeric".to_string())), TagValue::F64(0.0));
354+
355+
// Rational cases
356+
assert_eq!(safe_reciprocal(&TagValue::Rational(8, 2)), TagValue::F64(0.25)); // 1/(8/2) = 1/4
357+
assert_eq!(safe_reciprocal(&TagValue::Rational(0, 1)), TagValue::F64(0.0)); // 0 numerator
358+
assert_eq!(safe_reciprocal(&TagValue::SRational(6, 3)), TagValue::F64(0.5)); // 1/(6/3) = 1/2
359+
}
360+
}

0 commit comments

Comments
 (0)