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
7use std::collections::{HashMap, HashSet};
8use std::path::{Component, Path, PathBuf};
9
10use crate::car::{CSIItem, Car, ReferenceRect};
11use crate::model::Encoding;
12use crate::model::rendition::{LayoutType, Rendition};
13use crate::output::{self, OutputIdentity};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ExportFormat {
17    Json,
18    Png,
19    Jpeg,
20    Webp,
21    Heif,
22    Pdf,
23    Svg,
24    Raw,
25}
26
27impl ExportFormat {
28    pub fn extension(self) -> &'static str {
29        match self {
30            Self::Json => "json",
31            Self::Png => "png",
32            Self::Jpeg => "jpg",
33            Self::Webp => "webp",
34            Self::Heif => "heic",
35            Self::Pdf => "pdf",
36            Self::Svg => "svg",
37            Self::Raw => "bin",
38        }
39    }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ExportSkipReason {
44    UnsupportedPayload,
45    UnsafeFacetPath,
46    UnsafeFileName,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ExportSkip {
51    pub facet_name: String,
52    pub rendition_name: String,
53    pub reason: ExportSkipReason,
54}
55
56#[derive(Debug, Clone)]
57pub struct ExportJob<'a> {
58    pub logical_facet_name: String,
59    pub path: PathBuf,
60    pub logical_item: &'a CSIItem,
61    pub payload_item: &'a CSIItem,
62    pub output_identity: OutputIdentity<'a>,
63    pub format: ExportFormat,
64    pub crops: Vec<ReferenceRect>,
65}
66
67#[derive(Debug, Clone)]
68pub struct ExportPlan<'a> {
69    pub jobs: Vec<ExportJob<'a>>,
70    pub skipped: Vec<ExportSkip>,
71    pub canonical_coalesced: usize,
72}
73
74#[derive(Clone, Copy)]
75struct OrderedItem<'a> {
76    facet_name: &'a str,
77    item: &'a CSIItem,
78}
79
80#[derive(Clone)]
81struct PlannedItem<'a> {
82    logical: OrderedItem<'a>,
83    payload: OrderedItem<'a>,
84    output_identity: OutputIdentity<'a>,
85    crops: Vec<ReferenceRect>,
86}
87
88pub fn plan_export<'a>(car: &'a Car, output_root: impl AsRef<Path>) -> ExportPlan<'a> {
89    let output_root = output_root.as_ref();
90    let ordered_items = collect_ordered_items(car);
91    let mut name_counts: HashMap<PathBuf, usize> = HashMap::new();
92    let mut assigned_paths: HashSet<PathBuf> = HashSet::new();
93    let mut seen_canonical_outputs: HashSet<Vec<u16>> = HashSet::new();
94    let mut jobs = Vec::new();
95    let mut skipped = Vec::new();
96    let mut canonical_coalesced = 0usize;
97
98    for logical in ordered_items.iter().copied() {
99        let Some(planned) = resolve_planned_item(logical, &ordered_items, car) else {
100            skipped.push(export_skip(logical, ExportSkipReason::UnsupportedPayload));
101            continue;
102        };
103
104        if let Some(canonical_key) = planned.output_identity.canonical_identity_key()
105            && !seen_canonical_outputs.insert(canonical_key)
106        {
107            canonical_coalesced += 1;
108            continue;
109        }
110
111        let Some((path, format)) = build_planned_output_path(output_root, &planned) else {
112            skipped.push(export_skip(logical, ExportSkipReason::UnsafeFileName));
113            continue;
114        };
115        let Some(path) = assign_output_path(path, &mut name_counts, &mut assigned_paths) else {
116            skipped.push(export_skip(logical, ExportSkipReason::UnsafeFileName));
117            continue;
118        };
119
120        jobs.push(ExportJob {
121            logical_facet_name: planned.logical.facet_name.to_string(),
122            path,
123            logical_item: planned.logical.item,
124            payload_item: planned.payload.item,
125            output_identity: planned.output_identity,
126            format,
127            crops: planned.crops,
128        });
129    }
130
131    ExportPlan {
132        jobs,
133        skipped,
134        canonical_coalesced,
135    }
136}
137
138fn collect_ordered_items(car: &Car) -> Vec<OrderedItem<'_>> {
139    let mut ordered_items = Vec::new();
140    for (facet_name, facet) in car.named_facets() {
141        let Some(items) = car.items_for_facet(facet) else {
142            continue;
143        };
144        ordered_items.extend(items.iter().map(|item| OrderedItem { facet_name, item }));
145    }
146    ordered_items
147}
148
149fn resolve_planned_item<'a>(
150    logical: OrderedItem<'a>,
151    ordered_items: &[OrderedItem<'a>],
152    car: &'a Car,
153) -> Option<PlannedItem<'a>> {
154    let (payload, crops) = resolve_payload_item(logical, ordered_items, car)?;
155    let output_identity =
156        car.output_identity_for_payload(logical.facet_name, logical.item, payload.item)?;
157
158    Some(PlannedItem {
159        logical,
160        payload,
161        output_identity,
162        crops,
163    })
164}
165
166fn resolve_payload_item<'a>(
167    logical: OrderedItem<'a>,
168    ordered_items: &[OrderedItem<'a>],
169    car: &'a Car,
170) -> Option<(OrderedItem<'a>, Vec<ReferenceRect>)> {
171    if output::supported_output_identity(logical.item).is_some() {
172        return Some((logical, Vec::new()));
173    }
174
175    if !matches!(logical.item.layout(), LayoutType::InternalReference) {
176        return None;
177    }
178
179    if let Some(resolved) = car.resolve_internal_reference(logical.item) {
180        let source = OrderedItem {
181            facet_name: logical.facet_name,
182            item: resolved.source,
183        };
184        return Some((source, resolved.crops));
185    }
186
187    let fallback = find_same_facet_payload(logical, ordered_items)?;
188    Some((fallback, Vec::new()))
189}
190
191fn find_same_facet_payload<'a>(
192    logical: OrderedItem<'a>,
193    ordered_items: &[OrderedItem<'a>],
194) -> Option<OrderedItem<'a>> {
195    ordered_items.iter().copied().find(|candidate| {
196        candidate.facet_name == logical.facet_name
197            && !std::ptr::eq(candidate.item, logical.item)
198            && candidate.item.name() == logical.item.name()
199            && output::supported_output_identity(candidate.item).is_some()
200    })
201}
202
203fn build_planned_output_path(
204    output: &Path,
205    planned: &PlannedItem<'_>,
206) -> Option<(PathBuf, ExportFormat)> {
207    let facet_path = sanitize_relative_path(planned.output_identity.facet_name())?;
208    let (format, extension) =
209        planned_extension(planned.output_identity.item(), planned.payload.item)?;
210    let file_name = if matches!(
211        planned.payload.item.header().rendition,
212        Some(Rendition::Color(_)) | Some(Rendition::MultisizeImageSet(_))
213    ) {
214        planned_json_file_name(&planned.output_identity)?
215    } else {
216        output::suggested_file_name(&planned.output_identity, &extension)?
217    };
218    Some((output.join(facet_path).join(file_name), format))
219}
220
221pub fn sanitize_relative_path(path: &str) -> Option<PathBuf> {
222    let mut normalized = PathBuf::new();
223    let mut has_component = false;
224
225    for component in Path::new(path).components() {
226        match component {
227            Component::Normal(part) => {
228                normalized.push(part);
229                has_component = true;
230            }
231            Component::CurDir => {}
232            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
233        }
234    }
235
236    has_component.then_some(normalized)
237}
238
239fn planned_json_file_name(identity: &OutputIdentity<'_>) -> Option<String> {
240    let base_name = output::sanitize_file_name(identity.item().name())?
241        .file_name()
242        .map(|name| name.to_string_lossy().into_owned())
243        .unwrap_or_else(|| String::from("asset"));
244
245    let scale = output::rendition_scale(identity.item());
246    let stem = if identity.include_scale_suffix() && scale > 1 {
247        let suffix = format!("@{scale}x");
248        if base_name.ends_with(&suffix) {
249            base_name
250        } else {
251            format!("{base_name}{suffix}")
252        }
253    } else {
254        base_name
255    };
256
257    Some(format!("{stem}.json"))
258}
259
260fn planned_extension(
261    identity_item: &CSIItem,
262    payload_item: &CSIItem,
263) -> Option<(ExportFormat, String)> {
264    match &payload_item.header().rendition {
265        Some(Rendition::Color(_)) | Some(Rendition::MultisizeImageSet(_)) => Some((
266            ExportFormat::Json,
267            ExportFormat::Json.extension().to_string(),
268        )),
269        Some(Rendition::RawData(_)) => {
270            let format = raw_format(payload_item.header().encoding);
271            Some((format, preserved_file_extension(identity_item)?))
272        }
273        Some(Rendition::ThemeCBCK(_)) if should_save_raw_item(payload_item) => {
274            let format = raw_format_for_item(payload_item);
275            Some((
276                format,
277                output::default_raw_extension_for_item(payload_item).to_string(),
278            ))
279        }
280        Some(Rendition::ThemeCBCK(_)) => {
281            let (format, extension) = identity_item
282                .name()
283                .rsplit('.')
284                .next()
285                .and_then(image_extension)
286                .unwrap_or((ExportFormat::Png, "png".to_string()));
287            Some((format, extension))
288        }
289        _ => None,
290    }
291}
292
293fn preserved_file_extension(item: &CSIItem) -> Option<String> {
294    let file_name = output::sanitize_file_name(item.name())?;
295    Some(
296        file_name
297            .extension()
298            .map(|extension| extension.to_string_lossy().into_owned())
299            .unwrap_or_default(),
300    )
301}
302
303fn image_extension(extension: &str) -> Option<(ExportFormat, String)> {
304    let extension = extension.to_ascii_lowercase();
305    match extension.as_str() {
306        "png" => Some((ExportFormat::Png, extension)),
307        "jpg" | "jpeg" => Some((ExportFormat::Jpeg, extension)),
308        "webp" => Some((ExportFormat::Webp, extension)),
309        _ => None,
310    }
311}
312
313fn raw_format(encoding: Encoding) -> ExportFormat {
314    match encoding {
315        Encoding::HEIF => ExportFormat::Heif,
316        Encoding::PDF => ExportFormat::Pdf,
317        Encoding::SVG => ExportFormat::Svg,
318        Encoding::JPEG => ExportFormat::Jpeg,
319        Encoding::WEBP => ExportFormat::Webp,
320        _ => ExportFormat::Raw,
321    }
322}
323
324fn raw_format_for_item(item: &CSIItem) -> ExportFormat {
325    if crate::image_conv::is_hevc_compressed(item) {
326        ExportFormat::Heif
327    } else {
328        raw_format(item.header().encoding)
329    }
330}
331
332pub fn should_save_raw(encoding: Encoding) -> bool {
333    matches!(encoding, Encoding::HEIF | Encoding::PDF | Encoding::SVG)
334}
335
336pub fn should_save_raw_item(item: &CSIItem) -> bool {
337    crate::image_conv::is_hevc_compressed(item) || should_save_raw(item.header().encoding)
338}
339
340fn assign_output_path(
341    mut path: PathBuf,
342    name_counts: &mut HashMap<PathBuf, usize>,
343    assigned_paths: &mut HashSet<PathBuf>,
344) -> Option<PathBuf> {
345    if assigned_paths.contains(&path) {
346        let base_path = path.clone();
347        let stem = base_path.file_stem()?.to_string_lossy().to_string();
348        let ext = base_path
349            .extension()
350            .map(|ext| ext.to_string_lossy().to_string())
351            .unwrap_or_default();
352        let parent = base_path.parent().map(Path::to_path_buf);
353        let count = name_counts.entry(base_path).or_insert(0);
354
355        loop {
356            *count += 1;
357            let new_name = if ext.is_empty() {
358                format!("{stem}_{}", *count)
359            } else {
360                format!("{stem}_{}.{}", *count, ext)
361            };
362            let candidate = match &parent {
363                Some(parent) => parent.join(new_name),
364                None => PathBuf::from(new_name),
365            };
366            if !assigned_paths.contains(&candidate) {
367                path = candidate;
368                break;
369            }
370        }
371    }
372
373    assigned_paths.insert(path.clone());
374    Some(path)
375}
376
377fn export_skip(logical: OrderedItem<'_>, reason: ExportSkipReason) -> ExportSkip {
378    ExportSkip {
379        facet_name: logical.facet_name.to_string(),
380        rendition_name: logical.item.name().to_string(),
381        reason,
382    }
383}