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