Skip to main content

dezoomify_rs/
lib.rs

1#![deny(clippy::cognitive_complexity)]
2#![deny(clippy::too_many_lines)]
3#![deny(clippy::missing_errors_doc)]
4#![deny(clippy::missing_panics_doc)]
5#![deny(clippy::pedantic)]
6
7use std::env::current_dir;
8
9use std::io::BufRead;
10use std::path::{Path, PathBuf};
11use std::{fs, io};
12
13use log::{debug, error, info};
14
15pub use arguments::Arguments;
16pub use binary_display::{BinaryDisplay, display_bytes};
17use dezoomer::Dezoomer;
18use dezoomer::TileReference;
19use dezoomer::{ZoomLevel, ZoomLevelIter};
20pub use errors::ZoomError;
21use network::client;
22use output_file::get_outname;
23use tile::Tile;
24pub use vec2d::Vec2d;
25
26use crate::auto::MetadataResolver;
27use crate::dezoomer::{Images, ResolvedImage, ZoomableImage};
28use crate::encoder::SourceLevel;
29use crate::encoder::tile_buffer::TileBuffer;
30
31use crate::output_file::reserve_output_file;
32
33mod arguments;
34mod binary_display;
35
36pub mod dezoomer;
37pub(crate) mod download_state;
38mod encoder;
39mod errors;
40mod network;
41mod output_file;
42pub mod tile;
43mod vec2d;
44
45pub mod auto;
46pub mod bulk_text;
47pub mod custom_yaml;
48pub mod dzi;
49pub mod generic;
50pub mod google_arts_and_culture;
51pub mod iiif;
52pub mod iipimage;
53mod json_utils;
54pub mod krpano;
55pub mod nypl;
56pub mod pff;
57mod throttler;
58pub mod zoomify;
59
60fn stdin_line() -> Result<String, ZoomError> {
61    let stdin = std::io::stdin();
62    let mut lines = stdin.lock().lines();
63    let first_line = lines.next().ok_or_else(|| {
64        let err_msg = "Encountered end of standard input while reading a line";
65        io::Error::new(io::ErrorKind::UnexpectedEof, err_msg)
66    })?;
67    Ok(first_line?)
68}
69
70/// Resolve all metadata requested by a dezoomer.
71async fn get_images(
72    dezoomer: &mut dyn Dezoomer,
73    resolver: &mut MetadataResolver<'_>,
74    uri: &str,
75) -> Result<Images, ZoomError> {
76    resolver.resolve(dezoomer, uri).await.map_err(Into::into)
77}
78
79/// Process an input URI to extract zoomable images
80async fn get_images_from_uri(
81    args: &Arguments,
82    resolver: &mut MetadataResolver<'_>,
83    uri: &str,
84) -> Result<Vec<ZoomableImage>, ZoomError> {
85    let mut dezoomer = args.find_dezoomer()?;
86    Ok(get_images(dezoomer.as_mut(), resolver, uri)
87        .await?
88        .into_iter()
89        .collect())
90}
91
92/// Validates a user input line as a level index
93fn parse_level_index(input: &str, max_index: usize) -> Option<usize> {
94    input.parse::<usize>().ok().filter(|&idx| idx < max_index)
95}
96
97/// Gets the actual level index to use, handling out-of-bounds requests
98fn resolve_level_index(requested: usize, available_count: usize) -> usize {
99    if requested < available_count {
100        requested
101    } else {
102        available_count - 1
103    }
104}
105
106/// Gets the actual image index to use, handling out-of-bounds requests
107fn resolve_image_index(requested: usize, available_count: usize) -> usize {
108    if requested < available_count {
109        requested
110    } else {
111        available_count - 1
112    }
113}
114
115/// Finds the position of a level with the specified size hint
116fn find_level_with_size(levels: &[ZoomLevel], target_size: Vec2d) -> Option<usize> {
117    levels
118        .iter()
119        .position(|l| l.size_hint() == Some(target_size))
120}
121
122/// An interactive level picker
123fn level_picker(mut levels: Vec<ZoomLevel>) -> Result<ZoomLevel, ZoomError> {
124    println!("Found the following zoom levels:");
125    for (i, level) in levels.iter().enumerate() {
126        println!("{: >2}. {}", i, level.name());
127    }
128    loop {
129        println!("Which level do you want to download? ");
130        let line = stdin_line()?;
131        if let Some(idx) = parse_level_index(&line, levels.len()) {
132            return Ok(levels.swap_remove(idx));
133        }
134        error!("'{line}' is not a valid level number");
135    }
136}
137
138fn choose_level(mut levels: Vec<ZoomLevel>, args: &Arguments) -> Result<ZoomLevel, ZoomError> {
139    match levels.len() {
140        0 => Err(ZoomError::NoLevels),
141        1 => Ok(levels.swap_remove(0)),
142        _ => {
143            if let Some(requested_level) = args.zoom_level {
144                let actual_level = resolve_level_index(requested_level, levels.len());
145                if actual_level == requested_level {
146                    info!("Selected zoom level {requested_level} as requested");
147                } else {
148                    info!(
149                        "Requested zoom level {requested_level} not available. Using last one ({actual_level})"
150                    );
151                }
152                return Ok(levels.swap_remove(actual_level));
153            }
154
155            if let Some(best_size) = args.best_size(levels.iter().filter_map(|l| l.size_hint()))
156                && let Some(pos) = find_level_with_size(&levels, best_size)
157            {
158                return Ok(levels.swap_remove(pos));
159            }
160
161            level_picker(levels)
162        }
163    }
164}
165
166/// An interactive image picker for when multiple images are available
167fn image_picker(mut images: Vec<ZoomableImage>) -> Result<ZoomableImage, ZoomError> {
168    println!("Found the following images:");
169    for (i, image) in images.iter().enumerate() {
170        let title = image
171            .title()
172            .map_or_else(|| format!("Image {}", i + 1), str::to_string);
173        println!("{i: >2}. {title}");
174    }
175    loop {
176        println!("Which image do you want to download? ");
177        let line = stdin_line()?;
178        if let Some(idx) = parse_level_index(&line, images.len()) {
179            return Ok(images.swap_remove(idx));
180        }
181        error!("'{line}' is not a valid image number");
182    }
183}
184
185/// Choose an image from multiple options (interactive or automatic)
186fn choose_image(
187    mut images: Vec<ZoomableImage>,
188    args: &Arguments,
189) -> Result<ZoomableImage, ZoomError> {
190    match images.len() {
191        0 => Err(ZoomError::NoLevels),
192        1 => Ok(images.swap_remove(0)),
193        _ => {
194            if let Some(requested_index) = args.image_index {
195                let actual_index = resolve_image_index(requested_index, images.len());
196                if actual_index == requested_index {
197                    info!("Selected image {requested_index} as requested");
198                } else {
199                    info!(
200                        "Requested image index {requested_index} not available. Using last one ({actual_index})"
201                    );
202                }
203                return Ok(images.swap_remove(actual_index));
204            }
205
206            // In bulk mode, automatically select the first image to avoid interactive prompts
207            if args.is_bulk_mode() {
208                info!("Bulk mode: automatically selecting first image (index 0)");
209                return Ok(images.swap_remove(0));
210            }
211
212            // Interactive selection when no command line option is provided
213            image_picker(images)
214        }
215    }
216}
217
218async fn resolve_selected_image(
219    mut image: ZoomableImage,
220    args: &Arguments,
221    resolver: &mut MetadataResolver<'_>,
222) -> Result<ResolvedImage, ZoomError> {
223    loop {
224        match image {
225            ZoomableImage::Resolved(image) => return Ok(image),
226            ZoomableImage::Url(image_url) => {
227                let images = ZoomableImage::Url(image_url)
228                    .resolve_with(resolver)
229                    .await
230                    .map_err(|source| ZoomError::Dezoomer { source })?;
231                image = choose_image(images.into_iter().collect(), args)?;
232            }
233        }
234    }
235}
236
237/// Prepares the output file path for saving
238fn prepare_output_path(
239    outfile_arg: Option<&Path>,
240    title: Option<&str>,
241    base_dir: &Path,
242    size_hint: Option<Vec2d>,
243) -> Result<PathBuf, ZoomError> {
244    let outname = get_outname(outfile_arg, title, base_dir, size_hint);
245    let save_as = fs::canonicalize(outname.as_path()).unwrap_or_else(|_e| outname.clone());
246    reserve_output_file(&save_as)?;
247    Ok(save_as)
248}
249
250/// Creates a tile buffer for the given output path
251fn create_tile_buffer(save_as: PathBuf, compression: u8) -> TileBuffer {
252    TileBuffer::new(save_as, compression)
253}
254
255fn output_prefers_source_pyramid(path: &Path, args: &Arguments) -> bool {
256    if args.has_level_specifying_args() || args.largest {
257        return false;
258    }
259    matches!(
260        path.extension().and_then(|ext| ext.to_str()),
261        Some("iiif" | "tif" | "tiff" | "zif")
262    )
263}
264
265fn can_dezoomify_source_pyramid(path: &Path, args: &Arguments, levels: &[ZoomLevel]) -> bool {
266    output_prefers_source_pyramid(path, args)
267        && largest_level_size(levels).is_some()
268        && levels.iter().all(|level| {
269            level.size_hint().is_some()
270                && level.tile_size_hint().is_some()
271                && !level.has_overlapping_tiles()
272        })
273}
274
275async fn dezoomify_source_pyramid(
276    args: &Arguments,
277    mut levels: Vec<ZoomLevel>,
278    tile_buffer: TileBuffer,
279) -> Result<(), ZoomError> {
280    let mut canvas = tile_buffer;
281    let full_size = largest_level_size(&levels).ok_or(ZoomError::NoLevels)?;
282    let base_scale_factor = levels
283        .iter()
284        .filter(|level| level.size_hint() == Some(full_size))
285        .filter_map(|level| level.scale_factor_hint())
286        .filter(|&scale_factor| scale_factor > 0)
287        .min()
288        .unwrap_or(1);
289    levels.sort_by_key(|level| std::cmp::Reverse(level_area(level.size_hint())));
290
291    let mut total_tiles = 0;
292    let mut successful_tiles = 0;
293    for (index, zoom_level) in levels.into_iter().enumerate() {
294        let level_size = zoom_level.size_hint().unwrap_or(full_size);
295        let scale_factor =
296            source_level_scale_factor(full_size, level_size, &zoom_level, base_scale_factor);
297        canvas
298            .begin_level(SourceLevel {
299                index,
300                size: full_size,
301                scale_factor,
302                tile_size: zoom_level.tile_size_hint(),
303                has_overlapping_tiles: zoom_level.has_overlapping_tiles(),
304            })
305            .await?;
306        let state = dezoomify_level_into_buffer(args, zoom_level, &mut canvas).await?;
307        validate_download_success(&state)?;
308        total_tiles += state.total_tiles;
309        successful_tiles += state.successful_tiles;
310    }
311
312    finalize_canvas(&mut canvas).await?;
313    if successful_tiles < total_tiles {
314        Err(ZoomError::PartialDownload {
315            successful_tiles,
316            total_tiles,
317            destination: canvas.destination().to_string_lossy().to_string(),
318        })
319    } else {
320        Ok(())
321    }
322}
323
324fn source_level_scale_factor(
325    full_size: Vec2d,
326    level_size: Vec2d,
327    level: &ZoomLevel,
328    base_scale_factor: u32,
329) -> u32 {
330    source_level_scale_factor_from_hint(
331        full_size,
332        level_size,
333        level.scale_factor_hint(),
334        base_scale_factor,
335    )
336}
337
338fn source_level_scale_factor_from_hint(
339    full_size: Vec2d,
340    level_size: Vec2d,
341    scale_factor_hint: Option<u32>,
342    base_scale_factor: u32,
343) -> u32 {
344    if let Some(scale_factor) = scale_factor_hint
345        .filter(|&scale_factor| scale_factor > 0)
346        .filter(|scale_factor| scale_factor % base_scale_factor == 0)
347    {
348        return (scale_factor / base_scale_factor).max(1);
349    }
350    full_size.x.div_ceil(level_size.x).max(1)
351}
352
353fn largest_level_size(levels: &[ZoomLevel]) -> Option<Vec2d> {
354    levels
355        .iter()
356        .filter_map(|level| level.size_hint())
357        .max_by_key(|size| level_area(Some(*size)))
358}
359
360fn level_area(size: Option<Vec2d>) -> u64 {
361    size.map_or(0, |size| u64::from(size.x) * u64::from(size.y))
362}
363
364/// Downloads the image selected by `args` and returns its output path.
365///
366/// # Errors
367///
368/// Returns an error if the input cannot be resolved, no suitable level can be selected,
369/// output setup fails, or the image cannot be downloaded and encoded.
370pub async fn dezoomify(args: &Arguments) -> Result<PathBuf, ZoomError> {
371    let uri = args.choose_input_uri()?;
372    let http_client = client(args.headers(), args, Some(&uri))?;
373    let mut resolver = MetadataResolver::new(&http_client);
374    debug!("Trying to locate a zoomable image...");
375    let images = get_images_from_uri(args, &mut resolver, &uri).await?;
376    debug!("Found {} zoomable images", images.len());
377    let selected_image = choose_image(images, args)?;
378    let resolved_image = resolve_selected_image(selected_image, args, &mut resolver).await?;
379    let title = resolved_image.title().map(str::to_string);
380    let zoom_levels = resolved_image.into_zoom_levels();
381
382    let base_dir = current_dir()?;
383    let output_file = args.output_file();
384    let largest_size = largest_level_size(&zoom_levels);
385    let source_pyramid_path = get_outname(
386        output_file.as_deref(),
387        title.as_deref(),
388        &base_dir,
389        largest_size,
390    );
391
392    if can_dezoomify_source_pyramid(&source_pyramid_path, args, &zoom_levels) {
393        let save_as = prepare_output_path(
394            output_file.as_deref(),
395            title.as_deref(),
396            &base_dir,
397            largest_size,
398        )?;
399        let tile_buffer = create_tile_buffer(save_as.clone(), args.compression);
400        info!("Dezooming source pyramid with {} levels", zoom_levels.len());
401        dezoomify_source_pyramid(args, zoom_levels, tile_buffer).await?;
402        Ok(save_as)
403    } else {
404        let zoom_level = choose_level(zoom_levels, args)?;
405        let save_as = prepare_output_path(
406            output_file.as_deref(),
407            title.as_deref(),
408            &base_dir,
409            zoom_level.size_hint(),
410        )?;
411        let tile_buffer = create_tile_buffer(save_as.clone(), args.compression);
412        info!("Dezooming {}", zoom_level.name());
413        dezoomify_level(args, zoom_level, tile_buffer).await?;
414        Ok(save_as)
415    }
416}
417
418/// Statistics for bulk processing
419#[derive(Debug, Default)]
420pub struct BulkStats {
421    pub total_images: usize,
422    pub successful_images: usize,
423    pub failed_images: usize,
424    pub partial_downloads: usize,
425}
426
427impl BulkStats {
428    fn new() -> Self {
429        Self::default()
430    }
431
432    fn record_success(&mut self) {
433        self.successful_images += 1;
434    }
435
436    fn record_partial(&mut self) {
437        self.partial_downloads += 1;
438    }
439
440    fn record_failure(&mut self) {
441        self.failed_images += 1;
442    }
443
444    fn set_total(&mut self, total: usize) {
445        self.total_images = total;
446    }
447}
448
449/// Process every image discovered from a bulk input.
450///
451/// # Errors
452///
453/// Returns an error if the bulk source cannot be resolved or shared processing setup fails.
454/// Failures for individual images are recorded in the returned statistics.
455pub async fn process_bulk(args: &Arguments) -> Result<BulkStats, ZoomError> {
456    use log::{debug, trace};
457
458    debug!("Starting bulk processing mode");
459    trace!("Bulk processing arguments: {args:?}");
460
461    // Get the bulk file/URI from arguments
462    let bulk_uri = args.bulk.as_ref().ok_or_else(|| ZoomError::NoBulkUrl {
463        bulk_file_path: "No bulk source specified".to_string(),
464    })?;
465
466    debug!("Bulk source: {bulk_uri}");
467
468    // Discover images from the bulk source.
469    let http = client(std::iter::empty(), args, None)?;
470    let mut resolver = MetadataResolver::new(&http);
471    let mut dezoomer = args.find_dezoomer()?;
472    let images = get_images(dezoomer.as_mut(), &mut resolver, bulk_uri).await?;
473
474    let mut stats = BulkStats::new();
475    let base_dir = current_dir()?;
476
477    stats.set_total(images.len());
478    info!("Found {} images to process in bulk mode", images.len());
479    debug!(
480        "Images discovered: {:?}",
481        images
482            .iter()
483            .map(|img| img.title().unwrap_or("Untitled"))
484            .collect::<Vec<_>>()
485    );
486
487    process_bulk_zoomable_images(
488        images.into_iter().collect(),
489        args,
490        &mut resolver,
491        &mut stats,
492        &base_dir,
493    )
494    .await?;
495
496    // Log final statistics
497    info!("Bulk processing complete!");
498    info!("Total images: {}", stats.total_images);
499    info!("Successfully downloaded: {}", stats.successful_images);
500    info!("Partial downloads: {}", stats.partial_downloads);
501    info!("Failed downloads: {}", stats.failed_images);
502
503    debug!("Final bulk processing stats: {stats:?}");
504
505    Ok(stats)
506}
507
508/// Resolve and process images without fetching deferred metadata ahead of time.
509async fn process_bulk_zoomable_images(
510    images: Vec<ZoomableImage>,
511    args: &Arguments,
512    resolver: &mut MetadataResolver<'_>,
513    stats: &mut BulkStats,
514    base_dir: &Path,
515) -> Result<(), ZoomError> {
516    use std::collections::VecDeque;
517
518    let bulk_outfile = args.bulk_output_file();
519    let mut pending = VecDeque::from(images);
520    let mut index = 0;
521
522    while let Some(zoomable_image) = pending.pop_front() {
523        let image_title = zoomable_image
524            .title()
525            .map_or_else(|| format!("Image_{}", index + 1), str::to_string);
526
527        let resolved_image = match zoomable_image {
528            ZoomableImage::Resolved(image) => image,
529            image @ ZoomableImage::Url(_) => match image.resolve_with(resolver).await {
530                Ok(images) if !images.is_empty() => {
531                    let images = images.into_iter().collect::<Vec<_>>();
532                    stats.total_images += images.len() - 1;
533                    for image in images.into_iter().rev() {
534                        pending.push_front(image);
535                    }
536                    continue;
537                }
538                Ok(_) => {
539                    log::warn!(
540                        "No images found for image {} ('{}')",
541                        index + 1,
542                        image_title
543                    );
544                    stats.record_failure();
545                    index += 1;
546                    continue;
547                }
548                Err(e) => {
549                    log::warn!(
550                        "Failed to resolve image {} ('{}'): {}",
551                        index + 1,
552                        image_title,
553                        e
554                    );
555                    stats.record_failure();
556                    index += 1;
557                    continue;
558                }
559            },
560        };
561
562        process_bulk_image(
563            resolved_image,
564            &image_title,
565            index,
566            args,
567            stats,
568            base_dir,
569            bulk_outfile.as_deref(),
570        )
571        .await;
572        index += 1;
573    }
574
575    Ok(())
576}
577
578async fn process_bulk_image(
579    image: ResolvedImage,
580    image_title: &str,
581    index: usize,
582    args: &Arguments,
583    stats: &mut BulkStats,
584    base_dir: &Path,
585    bulk_outfile: Option<&Path>,
586) {
587    use log::{debug, trace, warn};
588
589    debug!(
590        "Preparing image {}/{}: {image_title}",
591        index + 1,
592        stats.total_images
593    );
594    let zoom_levels = image.into_zoom_levels();
595    trace!(
596        "Zoom levels for image {}: {} levels available",
597        index + 1,
598        zoom_levels.len()
599    );
600
601    let zoom_level = match choose_level(zoom_levels, args) {
602        Ok(zoom_level) => zoom_level,
603        Err(error) => {
604            warn!(
605                "Failed to choose a zoom level for image {} ('{image_title}'): {error}",
606                index + 1
607            );
608            stats.record_failure();
609            return;
610        }
611    };
612    debug!(
613        "Selected zoom level for image {}: {} ({}x{})",
614        index + 1,
615        zoom_level.name(),
616        zoom_level.size_hint().map_or(0, |s| s.x),
617        zoom_level.size_hint().map_or(0, |s| s.y)
618    );
619
620    let level_title = zoom_level.title().unwrap_or_else(|| image_title.to_owned());
621    let indexed_outfile = bulk_outfile.map(|path| generate_bulk_output_name(path, index));
622    let save_as = get_outname(
623        indexed_outfile.as_deref(),
624        Some(&level_title),
625        base_dir,
626        zoom_level.size_hint(),
627    );
628    if let Err(error) = reserve_output_file(&save_as) {
629        let file_name = save_as
630            .file_name()
631            .map_or_else(|| "unknown".into(), |name| name.to_string_lossy());
632        warn!(
633            "Failed to prepare output file '{file_name}' for image {} ('{image_title}'): {error}",
634            index + 1
635        );
636        stats.record_failure();
637        return;
638    }
639
640    info!(
641        "Processing image {}/{}: {} -> {}",
642        index + 1,
643        stats.total_images,
644        image_title,
645        save_as.file_name().unwrap_or_default().to_string_lossy()
646    );
647    let tile_buffer = create_tile_buffer(save_as.clone(), args.compression);
648    match dezoomify_level(args, zoom_level, tile_buffer).await {
649        Ok(()) => {
650            info!(
651                "Successfully saved image {} to {}",
652                index + 1,
653                save_as.display()
654            );
655            stats.record_success();
656        }
657        Err(ZoomError::PartialDownload {
658            successful_tiles,
659            total_tiles,
660            ..
661        }) => {
662            warn!(
663                "Image {} completed with partial download: {successful_tiles}/{total_tiles} tiles",
664                index + 1
665            );
666            stats.record_partial();
667        }
668        Err(error) => {
669            warn!(
670                "Failed to process image {} ('{image_title}'): {error}",
671                index + 1
672            );
673            stats.record_failure();
674        }
675    }
676}
677
678/// Generate a unique output filename for bulk processing
679fn generate_bulk_output_name(base_outfile: &Path, index: usize) -> PathBuf {
680    let mut result = base_outfile.to_path_buf();
681
682    if let Some(stem) = base_outfile.file_stem() {
683        if let Some(extension) = base_outfile.extension() {
684            let new_name = format!(
685                "{}_{}.{}",
686                stem.to_string_lossy(),
687                index + 1,
688                extension.to_string_lossy()
689            );
690            result.set_file_name(new_name);
691        } else {
692            let new_name = format!("{}_{}", stem.to_string_lossy(), index + 1);
693            result.set_file_name(new_name);
694        }
695    } else {
696        result.set_file_name(format!("dezoomified_{}.jpg", index + 1));
697    }
698
699    result
700}
701
702/// Validates the download success based on the final state.
703/// Validates that enough tiles were downloaded to proceed
704fn validate_download_success(state: &download_state::DownloadState) -> Result<(), ZoomError> {
705    if state.is_successful() {
706        Ok(())
707    } else {
708        Err(ZoomError::NoTile)
709    }
710}
711
712/// Determines final result based on download success rate
713fn determine_final_result(
714    state: &download_state::DownloadState,
715    destination: String,
716) -> Result<(), ZoomError> {
717    if state.has_partial_failure() {
718        Err(ZoomError::PartialDownload {
719            successful_tiles: state.successful_tiles,
720            total_tiles: state.total_tiles,
721            destination,
722        })
723    } else {
724        Ok(())
725    }
726}
727
728/// Downloads and encodes one zoom level into `tile_buffer`.
729///
730/// # Errors
731///
732/// Returns an error if tile downloading or output encoding fails, if no tile succeeds,
733/// or if only part of the image can be downloaded.
734pub async fn dezoomify_level(
735    args: &Arguments,
736    zoom_level: ZoomLevel,
737    tile_buffer: TileBuffer,
738) -> Result<(), ZoomError> {
739    debug!("Starting to dezoomify {zoom_level:?}");
740    let mut canvas = tile_buffer;
741    let state = dezoomify_level_into_buffer(args, zoom_level, &mut canvas).await?;
742    validate_download_success(&state)?;
743    finalize_canvas(&mut canvas).await?;
744    let destination = canvas.destination().to_string_lossy().to_string();
745    determine_final_result(&state, destination)
746}
747
748async fn dezoomify_level_into_buffer(
749    args: &Arguments,
750    mut zoom_level: ZoomLevel,
751    canvas: &mut TileBuffer,
752) -> Result<download_state::DownloadState, ZoomError> {
753    let mut coordinator = download_state::TileDownloadCoordinator::new(&zoom_level, args)?;
754    let mut state = download_state::DownloadState::new();
755    let progress = download_state::ProgressManager::new();
756
757    progress.set_computing_urls();
758
759    let mut zoom_level_iter = ZoomLevelIter::new(&mut zoom_level);
760
761    while let Some(tile_refs) = zoom_level_iter.next_tile_references() {
762        coordinator
763            .download_batch(tile_refs, canvas, &mut state, &progress, &zoom_level_iter)
764            .await?;
765
766        zoom_level_iter.set_fetch_result(state.create_fetch_result());
767    }
768
769    progress.finish();
770    Ok(state)
771}
772
773async fn finalize_canvas(canvas: &mut TileBuffer) -> Result<(), ZoomError> {
774    let progress = download_state::ProgressManager::new();
775    progress.set_finalizing();
776    canvas.finalize().await?;
777    progress.finish();
778    Ok(())
779}
780
781/// Returns the maximal size a tile can have in order to fit in a canvas of the given size
782#[must_use]
783pub fn max_size_in_rect(position: Vec2d, tile_size: Vec2d, canvas_size: Vec2d) -> Vec2d {
784    (position + tile_size).min(canvas_size) - position
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790    use clap::Parser;
791
792    #[test]
793    fn test_parse_level_index() {
794        assert_eq!(parse_level_index("0", 5), Some(0));
795        assert_eq!(parse_level_index("4", 5), Some(4));
796        assert_eq!(parse_level_index("5", 5), None); // Out of bounds
797        assert_eq!(parse_level_index("abc", 5), None); // Invalid number
798        assert_eq!(parse_level_index("", 5), None); // Empty string
799        assert_eq!(parse_level_index("2", 1), None); // Index too high
800    }
801
802    #[test]
803    fn test_resolve_level_index() {
804        assert_eq!(resolve_level_index(2, 5), 2); // Within bounds
805        assert_eq!(resolve_level_index(0, 5), 0); // First index
806        assert_eq!(resolve_level_index(4, 5), 4); // Last valid index
807        assert_eq!(resolve_level_index(10, 5), 4); // Out of bounds, use last
808        assert_eq!(resolve_level_index(100, 3), 2); // Way out of bounds
809    }
810
811    #[test]
812    fn test_resolve_image_index() {
813        assert_eq!(resolve_image_index(1, 3), 1); // Within bounds
814        assert_eq!(resolve_image_index(0, 3), 0); // First index
815        assert_eq!(resolve_image_index(2, 3), 2); // Last valid index
816        assert_eq!(resolve_image_index(5, 3), 2); // Out of bounds, use last
817        assert_eq!(resolve_image_index(100, 1), 0); // Way out of bounds
818    }
819
820    #[test]
821    fn test_max_size_in_rect() {
822        // Tile fits completely within canvas
823        assert_eq!(
824            max_size_in_rect(
825                Vec2d { x: 10, y: 10 },
826                Vec2d { x: 50, y: 50 },
827                Vec2d { x: 100, y: 100 }
828            ),
829            Vec2d { x: 50, y: 50 }
830        );
831
832        // Tile extends beyond canvas horizontally
833        assert_eq!(
834            max_size_in_rect(
835                Vec2d { x: 80, y: 10 },
836                Vec2d { x: 50, y: 50 },
837                Vec2d { x: 100, y: 100 }
838            ),
839            Vec2d { x: 20, y: 50 }
840        );
841
842        // Tile extends beyond canvas vertically
843        assert_eq!(
844            max_size_in_rect(
845                Vec2d { x: 10, y: 80 },
846                Vec2d { x: 50, y: 50 },
847                Vec2d { x: 100, y: 100 }
848            ),
849            Vec2d { x: 50, y: 20 }
850        );
851
852        // Tile extends beyond canvas in both dimensions
853        assert_eq!(
854            max_size_in_rect(
855                Vec2d { x: 90, y: 90 },
856                Vec2d { x: 50, y: 50 },
857                Vec2d { x: 100, y: 100 }
858            ),
859            Vec2d { x: 10, y: 10 }
860        );
861
862        // Tile at canvas edge
863        assert_eq!(
864            max_size_in_rect(
865                Vec2d { x: 0, y: 0 },
866                Vec2d { x: 100, y: 100 },
867                Vec2d { x: 100, y: 100 }
868            ),
869            Vec2d { x: 100, y: 100 }
870        );
871    }
872
873    #[test]
874    fn source_level_scale_factor_uses_relative_hints() {
875        assert_eq!(
876            source_level_scale_factor_from_hint(
877                Vec2d { x: 5156, y: 3816 },
878                Vec2d { x: 2578, y: 1908 },
879                Some(2),
880                1,
881            ),
882            2
883        );
884        assert_eq!(
885            source_level_scale_factor_from_hint(
886                Vec2d { x: 515, y: 381 },
887                Vec2d { x: 515, y: 381 },
888                Some(10),
889                10,
890            ),
891            1
892        );
893    }
894
895    #[test]
896    fn source_level_scale_factor_falls_back_for_unusable_hints() {
897        assert_eq!(
898            source_level_scale_factor_from_hint(
899                Vec2d { x: 5156, y: 3816 },
900                Vec2d { x: 2578, y: 1908 },
901                None,
902                1,
903            ),
904            2
905        );
906        assert_eq!(
907            source_level_scale_factor_from_hint(
908                Vec2d { x: 5156, y: 3816 },
909                Vec2d { x: 2578, y: 1908 },
910                Some(3),
911                2,
912            ),
913            2
914        );
915    }
916
917    #[test]
918    fn test_validate_download_success() {
919        let mut successful_state = download_state::DownloadState::new();
920        successful_state.record_success();
921        assert!(validate_download_success(&successful_state).is_ok());
922
923        let failed_state = download_state::DownloadState::new();
924        assert!(validate_download_success(&failed_state).is_err());
925    }
926
927    #[test]
928    fn test_determine_final_result() {
929        let destination = "test.jpg".to_string();
930
931        // Complete success - no partial failure
932        let mut success_state = download_state::DownloadState::new();
933        success_state.add_batch(10);
934        for _ in 0..10 {
935            success_state.record_success();
936        }
937        assert!(determine_final_result(&success_state, destination.clone()).is_ok());
938
939        // Partial failure
940        let mut partial_state = download_state::DownloadState::new();
941        partial_state.add_batch(10);
942        for _ in 0..8 {
943            partial_state.record_success();
944        }
945        let result = determine_final_result(&partial_state, destination.clone());
946        assert!(result.is_err());
947        if let Err(ZoomError::PartialDownload {
948            successful_tiles,
949            total_tiles,
950            ..
951        }) = result
952        {
953            assert_eq!(successful_tiles, 8);
954            assert_eq!(total_tiles, 10);
955        } else {
956            panic!("Expected PartialDownload error");
957        }
958    }
959
960    #[test]
961    fn test_find_level_with_size() {
962        // Since we can't easily create real ZoomLevel instances for testing,
963        // let's test the logic directly with a simpler approach
964        let sizes = [
965            Some(Vec2d { x: 100, y: 100 }),
966            Some(Vec2d { x: 200, y: 200 }),
967            None,
968            Some(Vec2d { x: 300, y: 300 }),
969        ];
970
971        let target_size = Vec2d { x: 200, y: 200 };
972        let position = sizes.iter().position(|&s| s == Some(target_size));
973        assert_eq!(position, Some(1));
974
975        let target_size_not_found = Vec2d { x: 400, y: 400 };
976        let position = sizes.iter().position(|&s| s == Some(target_size_not_found));
977        assert_eq!(position, None);
978    }
979
980    #[test]
981    fn test_generate_bulk_output_name() {
982        use std::path::Path;
983
984        // Test with extension
985        let base = Path::new("output.jpg");
986        assert_eq!(
987            generate_bulk_output_name(base, 0),
988            Path::new("output_1.jpg")
989        );
990        assert_eq!(
991            generate_bulk_output_name(base, 9),
992            Path::new("output_10.jpg")
993        );
994
995        // Test without extension
996        let base = Path::new("output");
997        assert_eq!(generate_bulk_output_name(base, 0), Path::new("output_1"));
998        assert_eq!(generate_bulk_output_name(base, 4), Path::new("output_5"));
999
1000        // Test with complex path
1001        let base = Path::new("/path/to/my_file.png");
1002        assert_eq!(
1003            generate_bulk_output_name(base, 2),
1004            Path::new("/path/to/my_file_3.png")
1005        );
1006
1007        // Test with no stem (edge case)
1008        let base = Path::new(".hidden");
1009        assert_eq!(generate_bulk_output_name(base, 0), Path::new(".hidden_1"));
1010    }
1011
1012    #[test]
1013    fn test_bulk_stats() {
1014        let mut stats = BulkStats::new();
1015
1016        // Test initial state
1017        assert_eq!(stats.total_images, 0);
1018        assert_eq!(stats.successful_images, 0);
1019        assert_eq!(stats.failed_images, 0);
1020        assert_eq!(stats.partial_downloads, 0);
1021
1022        // Test setting total
1023        stats.set_total(10);
1024        assert_eq!(stats.total_images, 10);
1025
1026        // Test recording different types of results
1027        stats.record_success();
1028        stats.record_success();
1029        stats.record_partial();
1030        stats.record_failure();
1031        stats.record_failure();
1032        stats.record_failure();
1033
1034        assert_eq!(stats.successful_images, 2);
1035        assert_eq!(stats.partial_downloads, 1);
1036        assert_eq!(stats.failed_images, 3);
1037        assert_eq!(stats.total_images, 10); // Should remain unchanged
1038    }
1039
1040    #[test]
1041    fn test_generate_bulk_output_name_edge_cases() {
1042        use std::path::Path;
1043
1044        // Test with multiple dots
1045        let base = Path::new("file.name.with.dots.jpg");
1046        assert_eq!(
1047            generate_bulk_output_name(base, 0),
1048            Path::new("file.name.with.dots_1.jpg")
1049        );
1050
1051        // Test with extension only
1052        let base = Path::new(".jpg");
1053        assert_eq!(generate_bulk_output_name(base, 0), Path::new(".jpg_1"));
1054
1055        // Test large index
1056        let base = Path::new("test.png");
1057        assert_eq!(
1058            generate_bulk_output_name(base, 999),
1059            Path::new("test_1000.png")
1060        );
1061
1062        // Test with Unicode characters
1063        let base = Path::new("测试文件.jpg");
1064        assert_eq!(
1065            generate_bulk_output_name(base, 0),
1066            Path::new("测试文件_1.jpg")
1067        );
1068    }
1069
1070    #[test]
1071    fn test_bulk_mode_outfile_prefers_explicit_outfile() {
1072        let args = Arguments::parse_from([
1073            "dezoomify-rs",
1074            "--bulk",
1075            "urls.txt",
1076            "from_positional.jpg",
1077            "explicit.jpg",
1078        ]);
1079        assert_eq!(args.bulk_output_file(), Some(PathBuf::from("explicit.jpg")));
1080    }
1081
1082    #[test]
1083    fn test_bulk_mode_outfile_does_not_use_input_uri() {
1084        let args = Arguments::parse_from(["dezoomify-rs", "--bulk", "urls.txt", "fallback.jpg"]);
1085        assert_eq!(args.bulk_output_file(), None);
1086    }
1087
1088    #[test]
1089    fn test_bulk_mode_outfile_option_overrides_positionals() {
1090        let args = Arguments::parse_from([
1091            "dezoomify-rs",
1092            "--bulk",
1093            "urls.txt",
1094            "positional-input.jpg",
1095            "--outfile",
1096            "from-option.jpg",
1097        ]);
1098        assert_eq!(
1099            args.bulk_output_file(),
1100            Some(PathBuf::from("from-option.jpg"))
1101        );
1102    }
1103}
1104
1105#[cfg(test)]
1106mod iiif_title_tests {
1107    use crate::iiif::determine_title;
1108    use crate::iiif::manifest_types::ExtractedImageInfo;
1109
1110    #[test]
1111    fn test_determine_title_all_components() {
1112        let image_info = ExtractedImageInfo {
1113            image_uri: "https://example.com/image.json".to_string(),
1114            manifest_label: Some("Manifest Title".to_string()),
1115            metadata_title: Some("Metadata Title".to_string()),
1116            canvas_label: Some("Canvas Label".to_string()),
1117            canvas_index: 0,
1118        };
1119
1120        let result = determine_title(&image_info);
1121        assert_eq!(
1122            result,
1123            Some("Manifest Title - Metadata Title - Canvas Label".to_string())
1124        );
1125    }
1126
1127    #[test]
1128    fn test_determine_title_manifest_and_canvas_only() {
1129        let image_info = ExtractedImageInfo {
1130            image_uri: "https://example.com/image.json".to_string(),
1131            manifest_label: Some("Book Title".to_string()),
1132            metadata_title: None,
1133            canvas_label: Some("Page 1".to_string()),
1134            canvas_index: 0,
1135        };
1136
1137        let result = determine_title(&image_info);
1138        assert_eq!(result, Some("Book Title - Page 1".to_string()));
1139    }
1140
1141    #[test]
1142    fn test_determine_title_canvas_only() {
1143        let image_info = ExtractedImageInfo {
1144            image_uri: "https://example.com/image.json".to_string(),
1145            manifest_label: None,
1146            metadata_title: None,
1147            canvas_label: Some("Single Page".to_string()),
1148            canvas_index: 0,
1149        };
1150
1151        let result = determine_title(&image_info);
1152        assert_eq!(result, Some("Single Page".to_string()));
1153    }
1154
1155    #[test]
1156    fn test_determine_title_no_duplicates() {
1157        // Test that duplicate titles are not repeated
1158        let image_info = ExtractedImageInfo {
1159            image_uri: "https://example.com/image.json".to_string(),
1160            manifest_label: Some("Same Title".to_string()),
1161            metadata_title: Some("Same Title".to_string()), // Duplicate
1162            canvas_label: Some("Different Label".to_string()),
1163            canvas_index: 0,
1164        };
1165
1166        let result = determine_title(&image_info);
1167        assert_eq!(result, Some("Same Title - Different Label".to_string()));
1168    }
1169
1170    #[test]
1171    fn test_determine_title_empty() {
1172        let image_info = ExtractedImageInfo {
1173            image_uri: "https://example.com/image.json".to_string(),
1174            manifest_label: None,
1175            metadata_title: None,
1176            canvas_label: None,
1177            canvas_index: 0,
1178        };
1179
1180        let result = determine_title(&image_info);
1181        assert_eq!(result, None);
1182    }
1183
1184    #[test]
1185    fn test_determine_title_metadata_only() {
1186        let image_info = ExtractedImageInfo {
1187            image_uri: "https://example.com/image.json".to_string(),
1188            manifest_label: None,
1189            metadata_title: Some("Metadata Only".to_string()),
1190            canvas_label: None,
1191            canvas_index: 0,
1192        };
1193
1194        let result = determine_title(&image_info);
1195        assert_eq!(result, Some("Metadata Only".to_string()));
1196    }
1197
1198    #[test]
1199    fn test_determine_title_special_characters() {
1200        let image_info = ExtractedImageInfo {
1201            image_uri: "https://example.com/image.json".to_string(),
1202            manifest_label: Some("Ms. Smith's \"Book\" & Notes (1850-1900)".to_string()),
1203            metadata_title: None,
1204            canvas_label: Some("Page #1: Introduction/Overview".to_string()),
1205            canvas_index: 0,
1206        };
1207
1208        let result = determine_title(&image_info);
1209        assert_eq!(
1210            result,
1211            Some(
1212                "Ms. Smith's \"Book\" & Notes (1850-1900) - Page #1: Introduction/Overview"
1213                    .to_string()
1214            )
1215        );
1216    }
1217
1218    #[test]
1219    fn test_determine_title_very_long() {
1220        let long_manifest = "A".repeat(100);
1221        let long_canvas = "B".repeat(100);
1222
1223        let image_info = ExtractedImageInfo {
1224            image_uri: "https://example.com/image.json".to_string(),
1225            manifest_label: Some(long_manifest.clone()),
1226            metadata_title: None,
1227            canvas_label: Some(long_canvas.clone()),
1228            canvas_index: 0,
1229        };
1230
1231        let result = determine_title(&image_info);
1232        let expected = format!("{long_manifest} - {long_canvas}");
1233        assert_eq!(result, Some(expected));
1234    }
1235
1236    #[test]
1237    fn test_determine_title_unicode() {
1238        let image_info = ExtractedImageInfo {
1239            image_uri: "https://example.com/image.json".to_string(),
1240            manifest_label: Some("古典文学作品集".to_string()),
1241            metadata_title: Some("詩經選讀".to_string()),
1242            canvas_label: Some("第一章:關雎".to_string()),
1243            canvas_index: 0,
1244        };
1245
1246        let result = determine_title(&image_info);
1247        assert_eq!(
1248            result,
1249            Some("古典文学作品集 - 詩經選讀 - 第一章:關雎".to_string())
1250        );
1251    }
1252
1253    #[test]
1254    fn test_determine_title_whitespace_handling() {
1255        let image_info = ExtractedImageInfo {
1256            image_uri: "https://example.com/image.json".to_string(),
1257            manifest_label: Some("  Manifest with spaces  ".to_string()),
1258            metadata_title: Some("\tTabbed metadata\t".to_string()),
1259            canvas_label: Some("Canvas\nwith\nnewlines".to_string()),
1260            canvas_index: 0,
1261        };
1262
1263        let result = determine_title(&image_info);
1264        // Note: The function doesn't currently trim whitespace, it preserves what's in the manifest
1265        assert_eq!(
1266            result,
1267            Some(
1268                "  Manifest with spaces   - \tTabbed metadata\t - Canvas\nwith\nnewlines"
1269                    .to_string()
1270            )
1271        );
1272    }
1273}