1use 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}