1use std::collections::{HashMap, HashSet};
10use std::path::{Component, Path, PathBuf};
11
12use crate::car::{CSIItem, Car, ReferenceRect};
13use crate::model::Encoding;
14use crate::model::rendition::{LayoutType, Rendition};
15use crate::output::{self, OutputIdentity};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ExportFormat {
19 Json,
20 Png,
21 Jpeg,
22 Webp,
23 Heif,
24 Pdf,
25 Svg,
26 Raw,
27}
28
29impl ExportFormat {
30 pub fn extension(self) -> &'static str {
31 match self {
32 Self::Json => "json",
33 Self::Png => "png",
34 Self::Jpeg => "jpg",
35 Self::Webp => "webp",
36 Self::Heif => "heic",
37 Self::Pdf => "pdf",
38 Self::Svg => "svg",
39 Self::Raw => "bin",
40 }
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ExportSkipReason {
46 UnsupportedPayload,
47 UnsafeFacetPath,
48 UnsafeFileName,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ExportSkip {
53 pub facet_name: String,
54 pub rendition_name: String,
55 pub reason: ExportSkipReason,
56}
57
58#[derive(Debug, Clone)]
59pub struct ExportJob<'a> {
60 pub logical_facet_name: String,
61 pub path: PathBuf,
62 pub logical_item: &'a CSIItem,
63 pub payload_item: &'a CSIItem,
64 pub output_identity: OutputIdentity<'a>,
65 pub format: ExportFormat,
66 pub crops: Vec<ReferenceRect>,
67}
68
69#[derive(Debug, Clone)]
70pub struct ExportPlan<'a> {
71 pub jobs: Vec<ExportJob<'a>>,
72 pub skipped: Vec<ExportSkip>,
73 pub canonical_coalesced: usize,
74}
75
76#[derive(Clone, Copy)]
77struct OrderedItem<'a> {
78 facet_name: &'a str,
79 item: &'a CSIItem,
80}
81
82#[derive(Clone)]
83struct PlannedItem<'a> {
84 logical: OrderedItem<'a>,
85 payload: OrderedItem<'a>,
86 output_identity: OutputIdentity<'a>,
87 crops: Vec<ReferenceRect>,
88}
89
90pub fn plan_export<'a>(car: &'a Car, output_root: impl AsRef<Path>) -> ExportPlan<'a> {
91 let output_root = output_root.as_ref();
92 let ordered_items = collect_ordered_items(car);
93 let mut name_counts: HashMap<PathBuf, usize> = HashMap::new();
94 let mut assigned_path_keys: HashSet<PathBuf> = HashSet::new();
95 let mut directory_path_keys: HashSet<PathBuf> = HashSet::new();
96 let mut pending_jobs = Vec::new();
97 let mut seen_canonical_outputs: HashSet<Vec<u16>> = HashSet::new();
98 let mut jobs = Vec::new();
99 let mut skipped = Vec::new();
100 let mut canonical_coalesced = 0usize;
101
102 for logical in ordered_items.iter().copied() {
103 let Some(planned) = resolve_planned_item(logical, &ordered_items, car) else {
104 skipped.push(export_skip(logical, ExportSkipReason::UnsupportedPayload));
105 continue;
106 };
107
108 if let Some(canonical_key) = planned.output_identity.canonical_identity_key()
109 && !seen_canonical_outputs.insert(canonical_key)
110 {
111 canonical_coalesced += 1;
112 continue;
113 }
114
115 let Some((path, format)) = build_planned_output_path(output_root, &planned) else {
116 skipped.push(export_skip(logical, ExportSkipReason::UnsafeFileName));
117 continue;
118 };
119 for parent in path.ancestors().skip(1) {
122 directory_path_keys.insert(output_path_key(parent));
123 }
124 pending_jobs.push((planned, path, format));
125 }
126
127 for (planned, path, format) in pending_jobs {
128 let Some(path) = assign_output_path(
129 path,
130 &mut name_counts,
131 &mut assigned_path_keys,
132 &directory_path_keys,
133 ) else {
134 skipped.push(export_skip(
135 planned.logical,
136 ExportSkipReason::UnsafeFileName,
137 ));
138 continue;
139 };
140
141 jobs.push(ExportJob {
142 logical_facet_name: planned.logical.facet_name.to_string(),
143 path,
144 logical_item: planned.logical.item,
145 payload_item: planned.payload.item,
146 output_identity: planned.output_identity,
147 format,
148 crops: planned.crops,
149 });
150 }
151
152 ExportPlan {
153 jobs,
154 skipped,
155 canonical_coalesced,
156 }
157}
158
159fn collect_ordered_items(car: &Car) -> Vec<OrderedItem<'_>> {
160 let mut ordered_items = Vec::new();
161 for (facet_name, facet) in car.named_facets() {
162 let Some(items) = car.items_for_facet(facet) else {
163 continue;
164 };
165 ordered_items.extend(items.iter().map(|item| OrderedItem { facet_name, item }));
166 }
167 ordered_items
168}
169
170fn resolve_planned_item<'a>(
171 logical: OrderedItem<'a>,
172 ordered_items: &[OrderedItem<'a>],
173 car: &'a Car,
174) -> Option<PlannedItem<'a>> {
175 let (payload, crops) = resolve_payload_item(logical, ordered_items, car)?;
176 let output_identity =
177 car.output_identity_for_payload(logical.facet_name, logical.item, payload.item)?;
178
179 Some(PlannedItem {
180 logical,
181 payload,
182 output_identity,
183 crops,
184 })
185}
186
187fn resolve_payload_item<'a>(
188 logical: OrderedItem<'a>,
189 ordered_items: &[OrderedItem<'a>],
190 car: &'a Car,
191) -> Option<(OrderedItem<'a>, Vec<ReferenceRect>)> {
192 if output::supported_output_identity(logical.item).is_some() {
193 return Some((logical, Vec::new()));
194 }
195
196 if !matches!(logical.item.layout(), LayoutType::InternalReference) {
197 return None;
198 }
199
200 if let Some(resolved) = car.resolve_internal_reference(logical.item) {
201 let source = OrderedItem {
202 facet_name: logical.facet_name,
203 item: resolved.source,
204 };
205 return Some((source, resolved.crops));
206 }
207
208 let fallback = find_same_facet_payload(logical, ordered_items)?;
209 Some((fallback, Vec::new()))
210}
211
212fn find_same_facet_payload<'a>(
213 logical: OrderedItem<'a>,
214 ordered_items: &[OrderedItem<'a>],
215) -> Option<OrderedItem<'a>> {
216 ordered_items.iter().copied().find(|candidate| {
217 candidate.facet_name == logical.facet_name
218 && !std::ptr::eq(candidate.item, logical.item)
219 && candidate.item.name() == logical.item.name()
220 && output::supported_output_identity(candidate.item).is_some()
221 })
222}
223
224fn build_planned_output_path(
225 output: &Path,
226 planned: &PlannedItem<'_>,
227) -> Option<(PathBuf, ExportFormat)> {
228 let facet_path = sanitize_relative_path(planned.output_identity.facet_name())?;
229 let (format, extension) =
230 planned_extension(planned.output_identity.item(), planned.payload.item)?;
231 let file_name = if matches!(
232 planned.payload.item.header().rendition,
233 Some(Rendition::Color(_)) | Some(Rendition::MultisizeImageSet(_))
234 ) {
235 planned_json_file_name(&planned.output_identity)?
236 } else {
237 output::suggested_file_name(&planned.output_identity, &extension)?
238 };
239 Some((output.join(facet_path).join(file_name), format))
240}
241
242pub fn sanitize_relative_path(path: &str) -> Option<PathBuf> {
243 let mut normalized = PathBuf::new();
244 let mut has_component = false;
245
246 for component in Path::new(path).components() {
247 match component {
248 Component::Normal(part) => {
249 normalized.push(part);
250 has_component = true;
251 }
252 Component::CurDir => {}
253 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
254 }
255 }
256
257 has_component.then_some(normalized)
258}
259
260fn planned_json_file_name(identity: &OutputIdentity<'_>) -> Option<String> {
261 let base_name = output::sanitize_file_name(identity.item().name())?
262 .file_name()
263 .map(|name| name.to_string_lossy().into_owned())
264 .unwrap_or_else(|| String::from("asset"));
265
266 let scale = output::rendition_scale(identity.item());
267 let stem = if identity.include_scale_suffix() && scale > 1 {
268 let suffix = format!("@{scale}x");
269 if base_name.ends_with(&suffix) {
270 base_name
271 } else {
272 format!("{base_name}{suffix}")
273 }
274 } else {
275 base_name
276 };
277
278 Some(format!("{stem}.json"))
279}
280
281fn planned_extension(
282 identity_item: &CSIItem,
283 payload_item: &CSIItem,
284) -> Option<(ExportFormat, String)> {
285 match &payload_item.header().rendition {
286 Some(Rendition::Color(_)) | Some(Rendition::MultisizeImageSet(_)) => Some((
287 ExportFormat::Json,
288 ExportFormat::Json.extension().to_string(),
289 )),
290 Some(Rendition::RawData(_)) => {
291 let format = raw_format(payload_item.header().encoding);
292 Some((format, preserved_file_extension(identity_item)?))
293 }
294 Some(Rendition::ThemeCBCK(_)) if should_save_raw_item(payload_item) => {
295 let format = raw_format_for_item(payload_item);
296 Some((
297 format,
298 output::default_raw_extension_for_item(payload_item).to_string(),
299 ))
300 }
301 Some(Rendition::ThemeCBCK(_)) => {
302 let (format, extension) = identity_item
303 .name()
304 .rsplit('.')
305 .next()
306 .and_then(image_extension)
307 .unwrap_or((ExportFormat::Png, "png".to_string()));
308 Some((format, extension))
309 }
310 _ => None,
311 }
312}
313
314fn preserved_file_extension(item: &CSIItem) -> Option<String> {
315 let file_name = output::sanitize_file_name(item.name())?;
316 Some(
317 file_name
318 .extension()
319 .map(|extension| extension.to_string_lossy().into_owned())
320 .unwrap_or_default(),
321 )
322}
323
324fn image_extension(extension: &str) -> Option<(ExportFormat, String)> {
325 let extension = extension.to_ascii_lowercase();
326 match extension.as_str() {
327 "png" => Some((ExportFormat::Png, extension)),
328 "jpg" | "jpeg" => Some((ExportFormat::Jpeg, extension)),
329 "webp" => Some((ExportFormat::Webp, extension)),
330 _ => None,
331 }
332}
333
334fn raw_format(encoding: Encoding) -> ExportFormat {
335 match encoding {
336 Encoding::HEIF => ExportFormat::Heif,
337 Encoding::PDF => ExportFormat::Pdf,
338 Encoding::SVG => ExportFormat::Svg,
339 Encoding::JPEG => ExportFormat::Jpeg,
340 Encoding::WEBP => ExportFormat::Webp,
341 _ => ExportFormat::Raw,
342 }
343}
344
345fn raw_format_for_item(item: &CSIItem) -> ExportFormat {
346 if crate::image_conv::is_hevc_compressed(item) {
347 ExportFormat::Heif
348 } else {
349 raw_format(item.header().encoding)
350 }
351}
352
353pub fn should_save_raw(encoding: Encoding) -> bool {
354 matches!(encoding, Encoding::HEIF | Encoding::PDF | Encoding::SVG)
355}
356
357pub fn should_save_raw_item(item: &CSIItem) -> bool {
358 crate::image_conv::is_hevc_compressed(item) || should_save_raw(item.header().encoding)
359}
360
361fn assign_output_path(
362 path: PathBuf,
363 name_counts: &mut HashMap<PathBuf, usize>,
364 assigned_path_keys: &mut HashSet<PathBuf>,
365 directory_path_keys: &HashSet<PathBuf>,
366) -> Option<PathBuf> {
367 let path_key = output_path_key(&path);
368 if !assigned_path_keys.contains(&path_key) && !directory_path_keys.contains(&path_key) {
369 assigned_path_keys.insert(path_key);
370 return Some(path);
371 }
372
373 let stem = path.file_stem()?.to_string_lossy().to_string();
374 let ext = path
375 .extension()
376 .map(|ext| ext.to_string_lossy().to_string())
377 .unwrap_or_default();
378 let parent = path.parent().map(Path::to_path_buf);
379 let count = name_counts.entry(path_key).or_insert(0);
380
381 loop {
382 *count += 1;
383 let new_name = if ext.is_empty() {
384 format!("{stem}_{}", *count)
385 } else {
386 format!("{stem}_{}.{}", *count, ext)
387 };
388 let candidate = match &parent {
389 Some(parent) => parent.join(new_name),
390 None => PathBuf::from(new_name),
391 };
392 let candidate_key = output_path_key(&candidate);
393 if !assigned_path_keys.contains(&candidate_key)
394 && !directory_path_keys.contains(&candidate_key)
395 {
396 assigned_path_keys.insert(candidate_key);
397 return Some(candidate);
398 }
399 }
400}
401
402fn output_path_key(path: &Path) -> PathBuf {
403 PathBuf::from(path.as_os_str().to_ascii_lowercase())
404}
405
406fn export_skip(logical: OrderedItem<'_>, reason: ExportSkipReason) -> ExportSkip {
407 ExportSkip {
408 facet_name: logical.facet_name.to_string(),
409 rendition_name: logical.item.name().to_string(),
410 reason,
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn case_only_directory_names_receive_distinct_output_paths() {
420 let mut counts = HashMap::new();
421 let mut assigned = HashSet::new();
422
423 let first = assign_output_path(
424 "out/Icon/image.png".into(),
425 &mut counts,
426 &mut assigned,
427 &HashSet::new(),
428 );
429 let second = assign_output_path(
430 "out/icon/image.png".into(),
431 &mut counts,
432 &mut assigned,
433 &HashSet::new(),
434 );
435
436 assert_eq!(first, Some("out/Icon/image.png".into()));
437 assert_eq!(second, Some("out/icon/image_1.png".into()));
438 }
439
440 #[test]
441 fn case_only_file_names_do_not_reuse_existing_suffixes() {
442 let mut counts = HashMap::new();
443 let mut assigned = HashSet::new();
444 let paths = [
445 "out/Icon/image.png",
446 "out/Icon/IMAGE_1.PNG",
447 "out/Icon/IMAGE.PNG",
448 "out/icon/image.png",
449 ]
450 .map(|path| {
451 assign_output_path(path.into(), &mut counts, &mut assigned, &HashSet::new()).unwrap()
452 });
453
454 assert_eq!(paths[0], Path::new("out/Icon/image.png"));
455 assert_eq!(paths[1], Path::new("out/Icon/IMAGE_1.PNG"));
456 assert_eq!(paths[2], Path::new("out/Icon/IMAGE_2.PNG"));
457 assert_eq!(paths[3], Path::new("out/icon/image_3.png"));
458 }
459
460 #[test]
461 fn case_only_names_without_extensions_receive_distinct_output_paths() {
462 let mut counts = HashMap::new();
463 let mut assigned = HashSet::new();
464
465 assign_output_path(
466 "out/Data/Contents".into(),
467 &mut counts,
468 &mut assigned,
469 &HashSet::new(),
470 )
471 .unwrap();
472 let second = assign_output_path(
473 "out/data/contents".into(),
474 &mut counts,
475 &mut assigned,
476 &HashSet::new(),
477 );
478
479 assert_eq!(second, Some("out/data/contents_1".into()));
480 }
481
482 fn assign_paths(paths: &[&str]) -> Vec<PathBuf> {
483 let directories = paths
484 .iter()
485 .flat_map(|path| Path::new(path).ancestors().skip(1))
486 .map(output_path_key)
487 .collect();
488 let mut counts = HashMap::new();
489 let mut assigned = HashSet::new();
490 paths
491 .iter()
492 .map(|path| {
493 assign_output_path((*path).into(), &mut counts, &mut assigned, &directories)
494 .unwrap()
495 })
496 .collect()
497 }
498
499 #[test]
500 fn files_avoid_directories_regardless_of_input_order() {
501 let paths = ["out/Icon/image.png", "out/Icon/image.png/image.png"];
502 assert_eq!(
503 assign_paths(&paths),
504 [PathBuf::from("out/Icon/image_1.png"), paths[1].into()]
505 );
506 assert_eq!(
507 assign_paths(&[paths[1], paths[0]]),
508 [PathBuf::from(paths[1]), "out/Icon/image_1.png".into()]
509 );
510 }
511
512 #[test]
513 fn numbered_candidates_avoid_case_insensitive_directory_names() {
514 assert_eq!(
515 assign_paths(&[
516 "out/Icon/image.png",
517 "out/Icon/IMAGE.PNG/image.png",
518 "out/Icon/IMAGE_1.PNG/image.png",
519 "out/Icon/IMAGE_2.PNG",
520 "out/Icon/image.png",
521 ]),
522 [
523 "out/Icon/image_2.png",
524 "out/Icon/IMAGE.PNG/image.png",
525 "out/Icon/IMAGE_1.PNG/image.png",
526 "out/Icon/IMAGE_2_1.PNG",
527 "out/Icon/image_3.png",
528 ]
529 .map(PathBuf::from)
530 );
531 }
532}