Skip to main content

lib3mf_cli/commands/
thumbnails.rs

1use anyhow::Result;
2use lib3mf_core::archive::ArchiveReader; // Trait must be in scope
3use lib3mf_core::model::ResourceId;
4use std::fs::{self, File};
5use std::io::Write; // Removed Read
6use std::path::PathBuf;
7
8/// Entry point for the `thumbnails` subcommand.
9///
10/// Depending on the flags provided, this function either lists all thumbnail
11/// attachments in the 3MF archive, extracts them to a directory, or injects
12/// a new thumbnail image into the archive.
13pub fn run(
14    file: PathBuf,
15    list: bool,
16    extract: Option<PathBuf>,
17    inject: Option<PathBuf>,
18    oid: Option<u32>,
19) -> Result<()> {
20    if list {
21        run_list(&file)?;
22        return Ok(());
23    }
24
25    if let Some(dir) = extract {
26        run_extract(&file, dir)?;
27        return Ok(());
28    }
29
30    if let Some(img_path) = inject {
31        run_inject(&file, img_path, oid)?;
32        return Ok(());
33    }
34
35    // Default or help usage if no flags?
36    println!("Please specify --list, --extract <DIR>, or --inject <IMG>.");
37    Ok(())
38}
39
40fn run_list(file: &PathBuf) -> Result<()> {
41    let mut archiver = crate::commands::open_archive(file)?;
42    let model_path = lib3mf_core::archive::find_model_path(&mut archiver)?;
43    let model_data = archiver.read_entry(&model_path)?;
44    let model = lib3mf_core::parser::parse_model(std::io::Cursor::new(model_data))?;
45
46    println!("Thumbnail Status for: {:?}", file);
47
48    // Package Thumbnail check
49    let pkg_thumb = archiver.entry_exists("Metadata/thumbnail.png")
50        || archiver.entry_exists("/Metadata/thumbnail.png");
51    println!(
52        "Package Thumbnail: {}",
53        if pkg_thumb { "Yes" } else { "No" }
54    );
55
56    // Parse Model Relationships to resolve thumbnail IDs to paths
57    let model_rels_path = {
58        let path = std::path::Path::new(&model_path);
59        if let Some(parent) = path.parent() {
60            let fname = path.file_name().unwrap_or_default().to_string_lossy();
61            parent
62                .join("_rels")
63                .join(format!("{}.rels", fname))
64                .to_string_lossy()
65                .replace("\\", "/")
66        } else {
67            format!("_rels/{}.rels", model_path)
68        }
69    };
70
71    let model_rels_data = archiver.read_entry(&model_rels_path).unwrap_or_default();
72    let model_rels = if !model_rels_data.is_empty() {
73        lib3mf_core::archive::opc::parse_relationships(&model_rels_data).unwrap_or_default()
74    } else {
75        Vec::new()
76    };
77
78    // Build Map: Relationship ID -> Target Path
79    let mut rel_map = std::collections::HashMap::new();
80    for rel in model_rels {
81        rel_map.insert(rel.id, rel.target);
82    }
83
84    // Objects
85    if model.resources.iter_objects().count() > 0 {
86        println!("\nObjects:");
87        for obj in model.resources.iter_objects() {
88            let thumb_display = if let Some(thumb_ref) = &obj.thumbnail {
89                // Resolve relationship ID to actual path
90                rel_map
91                    .get(thumb_ref)
92                    .map(|s| s.as_str())
93                    .unwrap_or(thumb_ref) // Fallback to raw value if not found
94            } else {
95                "None"
96            };
97            let name = obj.name.as_deref().unwrap_or("-");
98            println!(
99                "  ID: {:<4} | Name: {:<20} | Thumbnail: {}",
100                obj.id.0, name, thumb_display
101            );
102        }
103    } else {
104        println!("\nNo objects found.");
105    }
106    Ok(())
107}
108
109// ... run_extract is fine (I just fixed imports for it) ... I should preserve it.
110// But replace_file_content replaces a block.
111// I need to skip run_extract replacement if I can target correctly.
112// I will target only run_list and run_inject.
113// BUT `run_extract` is BETWEEN them in the file Step 133?
114// No, Step 133 order: run, run_list, run_extract, run_inject.
115// So I should replace run_list separate from run_inject?
116// Or replace the whole file content after `run`?
117// I'll replace `run_list` first.
118
119// Wait, I can't overwrite `run_extract` because I made changes to it in Step 188.
120// I should inspect the file again to be safe on line numbers?
121// Step 239 was minimal diff.
122// I'll view the file to get current state.
123// Better safe than sorry.
124
125fn run_inject(file: &PathBuf, img_path: PathBuf, oid: Option<u32>) -> Result<()> {
126    // We need to preserve existing attachments!
127    let mut archiver = crate::commands::open_archive(file)?;
128    let model_path = lib3mf_core::archive::find_model_path(&mut archiver)?;
129    let model_data = archiver.read_entry(&model_path)?;
130    let mut model = lib3mf_core::parser::parse_model(std::io::Cursor::new(model_data))?;
131
132    // Load ALL existing files as attachments (excluding system files)
133    // Also load .rels files to preserve multi-part relationships
134    let all_files = archiver.list_entries()?;
135    for entry_path in all_files {
136        // Skip files that PackageWriter regenerates
137        if entry_path == model_path
138            || entry_path == "_rels/.rels"
139            || entry_path == "[Content_Types].xml"
140        {
141            continue;
142        }
143
144        // Load .rels files separately to preserve relationships
145        if entry_path.ends_with(".rels") {
146            if let Ok(data) = archiver.read_entry(&entry_path)
147                && let Ok(rels) = lib3mf_core::archive::opc::parse_relationships(&data)
148            {
149                model.existing_relationships.insert(entry_path, rels);
150            }
151            continue;
152        }
153
154        // Load other data as attachments
155        if let Ok(data) = archiver.read_entry(&entry_path) {
156            model.attachments.insert(entry_path, data);
157        }
158    }
159
160    println!("Injecting {:?} into {:?}", img_path, file);
161
162    let img_data = fs::read(&img_path)?;
163
164    if let Some(id) = oid {
165        // Object Injection
166        let rid = ResourceId(id);
167
168        let mut found = false;
169        for obj in model.resources.iter_objects_mut() {
170            if obj.id == rid {
171                // Set path
172                let path = format!("3D/Textures/thumb_{}.png", id);
173                obj.thumbnail = Some(path.clone());
174
175                // Add attachment
176                model.attachments.insert(path, img_data.clone());
177                println!("Updated Object {} thumbnail.", id);
178                found = true;
179                break;
180            }
181        }
182        if !found {
183            anyhow::bail!("Object ID {} not found.", id);
184        }
185    } else {
186        // Package Injection
187        let path = "Metadata/thumbnail.png".to_string();
188        model.attachments.insert(path, img_data);
189        println!("Updated Package Thumbnail.");
190    }
191
192    // Write back
193    let f = File::create(file)?;
194    model
195        .write(f)
196        .map_err(|e| anyhow::anyhow!("Failed to write 3MF: {}", e))?;
197
198    println!("Done.");
199    Ok(())
200}
201
202fn run_extract(file: &PathBuf, dir: PathBuf) -> Result<()> {
203    // We need the archiver to read relationships
204    let mut archiver = crate::commands::open_archive(file)?;
205
206    // Parse Model (to get objects)
207    // Note: open_archive returns ZipArchiver. We need to find model path.
208    let model_path_str = lib3mf_core::archive::find_model_path(&mut archiver)?;
209    let model_data = archiver.read_entry(&model_path_str)?;
210    let model = lib3mf_core::parser::parse_model(std::io::Cursor::new(model_data))?;
211
212    // Load Attachments (manually, since parse_model doesn't use archiver automatically to populate attachments?
213    // Wait, parse_model ONLY parses XML. It doesn't load attachments.
214    // The previously used `open_model` helper did `ZipArchiver::new` but returned `Model`.
215    // Wait, `open_model` in `commands.rs` (Step 128) lines 38-81:
216    // It returns `ModelSource`.
217    // `ModelSource::Archive` holds `ZipArchiver` and `Model`.
218    // But `parse_model` returns `Model`.
219    // The `Model` returned by `parse_model` has EMPTY attachments!
220    // Attachments are loaded by `Model::load_attachments`?
221    // `lib3mf-core` allows lazy loading or expected the caller to fill `attachments`?
222    // Let's check `open_model` implementation again.
223    // Line 54: `let model = parse_model(...)`.
224    // It DOES NOT load attachments!
225    // So `model.attachments` is empty in `thumbnails.rs` when using `open_model`!
226    // This is a bug in my `thumbnails.rs` implementation (and potentially `stats` if it relies on attachments).
227    // `stats` relies on `model.compute_stats` which takes `archiver`.
228    // `lib3mf-core`'s `compute_stats` doesn't load attachments into the model struct, but accesses archiver.
229    // But `stats.rs` (my update) checks `self.attachments`.
230    // THIS MEANS `stats` (CLI) will report "No Package Thumbnail" because `model.attachments` is empty.
231
232    // I need to fix `thumbnails.rs` to load attachments or access them via archiver.
233    // And `stats_impl.rs` check `self.attachments` is WRONG if they aren't loaded.
234    // `stats_impl.rs` should check `archiver` for the file existence!
235
236    // Correction for `stats_impl.rs`:
237    // It has `archiver` available in `compute_stats`.
238    // `let pkg_thumb = archiver.entry_exists("Metadata/thumbnail.png") || archiver.entry_exists("/Metadata/thumbnail.png");`
239
240    // Correction for `thumbnails.rs`:
241    // I need to use `archiver` to read files.
242
243    fs::create_dir_all(&dir)?;
244    println!("Extracting thumbnails to {:?}...", dir);
245
246    // 1. Package Thumbnail
247    // Check various common paths or check relationships?
248    // Ideally check _rels/.rels to find the target of the thumbnail relationship.
249    // Parsing _rels/.rels
250    let global_rels_data = archiver.read_entry("_rels/.rels").unwrap_or_default();
251    let global_rels = if !global_rels_data.is_empty() {
252        lib3mf_core::archive::opc::parse_relationships(&global_rels_data).unwrap_or_default()
253    } else {
254        Vec::new()
255    };
256
257    let mut pkg_thumb_path = None;
258    for rel in global_rels {
259        if rel.rel_type.ends_with("metadata/thumbnail") {
260            pkg_thumb_path = Some(rel.target);
261            break;
262        }
263    }
264    // Fallback
265    if pkg_thumb_path.is_none() && archiver.entry_exists("Metadata/thumbnail.png") {
266        pkg_thumb_path = Some("Metadata/thumbnail.png".to_string());
267    }
268
269    if let Some(path) = pkg_thumb_path
270        && let Ok(data) = archiver.read_entry(&path)
271    {
272        let out = dir.join("package_thumbnail.png");
273        let mut f = File::create(&out)?;
274        f.write_all(&data)?;
275        println!("  Extracted Package Thumbnail: {:?}", out);
276    }
277
278    // 2. Object Thumbnails
279    // Parse Model Relationships
280    // Path is e.g. "3D/_rels/3dmodel.model.rels" (if main model is "3D/3dmodel.model")
281    // We need to construct the rels path from `model_path_str`.
282    // e.g. "3D/3dmodel.model" -> "3D/_rels/3dmodel.model.rels"
283    let model_rels_path = {
284        let path = std::path::Path::new(&model_path_str);
285        if let Some(parent) = path.parent() {
286            let fname = path.file_name().unwrap_or_default().to_string_lossy();
287            parent
288                .join("_rels")
289                .join(format!("{}.rels", fname))
290                .to_string_lossy()
291                .replace("\\", "/")
292        } else {
293            format!("_rels/{}.rels", model_path_str) // Unlikely for root file but possible
294        }
295    };
296
297    let model_rels_data = archiver.read_entry(&model_rels_path).unwrap_or_default();
298    let model_rels = if !model_rels_data.is_empty() {
299        lib3mf_core::archive::opc::parse_relationships(&model_rels_data).unwrap_or_default()
300    } else {
301        Vec::new()
302    };
303
304    // Build Map ID -> Target
305    let mut rel_map = std::collections::HashMap::new();
306    for rel in model_rels {
307        rel_map.insert(rel.id, rel.target);
308    }
309
310    for obj in model.resources.iter_objects() {
311        if let Some(thumb_ref) = &obj.thumbnail {
312            // Resolve ref
313            let target = rel_map.get(thumb_ref).cloned().or_else(|| {
314                // Maybe it IS a path (legacy or incorrectly written)?
315                Some(thumb_ref.clone())
316            });
317
318            if let Some(path) = target {
319                // Read from archiver
320                let lookup_path = path.trim_start_matches('/');
321                if let Ok(bytes) = archiver.read_entry(lookup_path) {
322                    let fname = format!("obj_{}_thumbnail.png", obj.id.0);
323                    let out = dir.join(fname);
324                    let mut f = File::create(&out)?;
325                    f.write_all(&bytes)?;
326                    println!("  Extracted Object {} Thumbnail: {:?}", obj.id.0, out);
327                } else {
328                    println!(
329                        "  Warning: Object {} thumbnail target '{}' not found in archive.",
330                        obj.id.0, lookup_path
331                    );
332                }
333            }
334        }
335    }
336
337    Ok(())
338}