Skip to main content

car/
export.rs

1//! Deterministic export planning for `.car` assets.
2//!
3//! Planning is intentionally side-effect free: it resolves payload items,
4//! computes output identities, sanitizes paths, coalesces canonical sources, and
5//! assigns duplicate suffixes without creating directories or writing files.
6//! Files avoid reserved facet directories, and duplicate matching ignores ASCII
7//! case to keep plans portable across filesystems.
8
9use 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        // Reserve every directory before assigning file names. Otherwise a later
120        // facet could require a directory already assigned to an earlier file.
121        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}