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
70async 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
79async 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
92fn parse_level_index(input: &str, max_index: usize) -> Option<usize> {
94 input.parse::<usize>().ok().filter(|&idx| idx < max_index)
95}
96
97fn 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
106fn 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
115fn 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
122fn 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
166fn 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
185fn 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 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 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
237fn 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
250fn 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
364pub 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#[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
449pub 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 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 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 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
508async 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
678fn 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
702fn 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
712fn 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
728pub 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#[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); assert_eq!(parse_level_index("abc", 5), None); assert_eq!(parse_level_index("", 5), None); assert_eq!(parse_level_index("2", 1), None); }
801
802 #[test]
803 fn test_resolve_level_index() {
804 assert_eq!(resolve_level_index(2, 5), 2); assert_eq!(resolve_level_index(0, 5), 0); assert_eq!(resolve_level_index(4, 5), 4); assert_eq!(resolve_level_index(10, 5), 4); assert_eq!(resolve_level_index(100, 3), 2); }
810
811 #[test]
812 fn test_resolve_image_index() {
813 assert_eq!(resolve_image_index(1, 3), 1); assert_eq!(resolve_image_index(0, 3), 0); assert_eq!(resolve_image_index(2, 3), 2); assert_eq!(resolve_image_index(5, 3), 2); assert_eq!(resolve_image_index(100, 1), 0); }
819
820 #[test]
821 fn test_max_size_in_rect() {
822 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 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 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 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 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 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 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 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 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 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 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 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 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 stats.set_total(10);
1024 assert_eq!(stats.total_images, 10);
1025
1026 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); }
1039
1040 #[test]
1041 fn test_generate_bulk_output_name_edge_cases() {
1042 use std::path::Path;
1043
1044 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 let base = Path::new(".jpg");
1053 assert_eq!(generate_bulk_output_name(base, 0), Path::new(".jpg_1"));
1054
1055 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 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 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()), 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 assert_eq!(
1266 result,
1267 Some(
1268 " Manifest with spaces - \tTabbed metadata\t - Canvas\nwith\nnewlines"
1269 .to_string()
1270 )
1271 );
1272 }
1273}