1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
use std::path::PathBuf;
use std::time::Duration;
use clap::Parser;
use regex::Regex;
use crate::dezoomer::Dezoomer;
use super::{Vec2d, ZoomError, auto, stdin_line};
#[derive(Parser, Debug, Clone)]
#[command(author, version, about, disable_help_flag = true)]
pub struct Arguments {
/// Displays this help message
#[arg(short = '?', long = "help", action = clap::ArgAction::Help)]
pub display_help: (),
/// Input URL or local file name. By default, the program will ask for it interactively.
pub input_uri: Option<String>,
/// File to which the resulting image should be saved. By default the program will
/// generate a name based on the image metadata if available. Otherwise, it will
/// generate a name in the format `dezoomified[_N].{jpg,png}` depending on which
/// files already exist in the current directory, and whether the target image size fits
/// in a JPEG or not.
#[arg()]
pub outfile: Option<PathBuf>,
/// File to which the resulting image should be saved.
/// This is an explicit alternative to the positional OUTFILE argument.
#[arg(long = "outfile", conflicts_with = "outfile")]
pub outfile_option: Option<PathBuf>,
/// Name of the dezoomer to use. "auto" will try to detect the format automatically
#[arg(short, long, default_value = "auto")]
dezoomer: String,
/// If several zoom levels are available, select the largest one (highest resolution)
#[arg(short, long)]
pub largest: bool,
/// If several zoom levels are available, select the one with the largest width that
/// does not exceed this value (in pixels)
#[arg(short = 'w', long = "max-width")]
max_width: Option<u32>,
/// If several zoom levels are available, select the one with the largest height that
/// does not exceed this value (in pixels)
#[arg(short = 'h', long = "max-height")]
max_height: Option<u32>,
/// Select a specific zoom level by its index (0-based). Use 0 for the smallest, 1 for the next level up, etc.
/// If the specified level doesn't exist, falls back to the highest available level
#[arg(long = "zoom-level")]
pub zoom_level: Option<usize>,
/// Select a specific image by its index (0-based) when multiple images are found.
/// If not specified, the program will ask interactively when multiple images are available.
/// If the specified index doesn't exist, falls back to the last one.
#[arg(long = "image-index")]
pub image_index: Option<usize>,
/// Degree of parallelism to use. At most this number of
/// tiles will be downloaded at the same time.
#[arg(short = 'n', long = "parallelism", default_value = "16")]
pub parallelism: usize,
/// Number of new attempts to make when a tile load fails
/// before giving up. Setting this to 0 is useful to speed up the
/// generic dezoomer, which relies on failed tile loads to detect the
/// dimensions of the image. On the contrary, if a server is not reliable,
/// set this value to a higher number.
#[arg(short = 'r', long = "retries", default_value = "1")]
pub retries: usize,
/// Amount of time to wait before retrying a request that failed.
/// Applies only to the first retry. Subsequent retries follow an
/// exponential backoff strategy: each one is twice as long as
/// the previous one.
#[arg(long, default_value = "2s", value_parser = parse_duration)]
pub retry_delay: Duration,
/// A number between 0 and 100 expressing how much to compress the output image.
/// For lossy output formats such as jpeg, this affects the quality of the resulting image.
/// 0 means less compression, 100 means more compression.
/// Currently affects only the JPEG and PNG encoders.
#[arg(long, default_value = "5")]
pub compression: u8,
/// Sets an HTTP header to use on requests.
/// This option can be repeated in order to set multiple headers.
/// You can use `-H "Referer: URL"` where URL is the URL of the website's
/// viewer page in order to let the site think you come from the legitimate viewer.
#[arg(
short = 'H',
long = "header",
value_parser = parse_header,
number_of_values = 1
)]
pub headers: Vec<(String, String)>,
/// Maximum number of idle connections per host allowed at the same time
#[arg(long, default_value = "32")]
pub max_idle_per_host: usize,
/// Whether to accept connecting to insecure HTTPS servers
#[arg(long)]
pub accept_invalid_certs: bool,
/// Minimum amount of time to wait between two consequent requests.
/// This throttles the flow of image tile requests coming from your computer,
/// reducing the risk of crashing the remote server of getting banned for making too many
/// requests in a short succession.
#[arg(short = 'i', long, default_value = "50ms", value_parser = parse_duration)]
pub min_interval: Duration,
/// Maximum time between the beginning of a request and the end of a response before
///the request should be interrupted and considered failed
#[arg(long, default_value = "30s", value_parser = parse_duration)]
pub timeout: Duration,
/// Time after which we should give up when trying to connect to a server
#[arg(long = "connect-timeout", default_value = "6s", value_parser = parse_duration)]
pub connect_timeout: Duration,
/// Level of logging verbosity. Set it to "debug" to get all logging messages.
#[arg(long, default_value = "info")]
pub logging: String,
/// A place to store the image tiles when after they are downloaded and decrypted.
/// By default, tiles are not stored to disk (which is faster), but using a tile cache allows
/// retrying partially failed downloads, or stitching the tiles with an external program.
#[arg(short = 'c', long = "tile-cache")]
pub tile_storage_folder: Option<PathBuf>,
/// URL or path to a text file containing a list of URLs to process in bulk mode.
/// Each line in the file should contain one URL, optionally followed by a custom title.
/// Format: URL [custom title]
/// Lines starting with # are treated as comments and ignored.
/// Accepts both local file paths and HTTP(S) URLs.
/// Can also directly process IIIF manifests to download all images with enhanced metadata-based filenames.
/// In bulk mode, if no level-specifying argument is defined (such as --max-width), then --largest is implied.
#[arg(long = "bulk")]
pub bulk: Option<String>,
}
impl Default for Arguments {
fn default() -> Self {
Arguments {
display_help: (),
input_uri: None,
outfile: None,
outfile_option: None,
dezoomer: "auto".to_string(),
largest: false,
max_width: None,
max_height: None,
zoom_level: None,
image_index: None,
parallelism: 16,
retries: 1,
compression: 5,
retry_delay: Duration::from_secs(2),
headers: vec![],
max_idle_per_host: 32,
accept_invalid_certs: false,
min_interval: Duration::default(),
timeout: Duration::from_secs(30),
connect_timeout: Duration::from_secs(6),
logging: "info".to_string(),
tile_storage_folder: None,
bulk: None,
}
}
}
impl Arguments {
/// Returns the configured input URI, or reads one from standard input.
///
/// # Errors
///
/// Returns an error if no URI was configured and standard input cannot be read.
pub fn choose_input_uri(&self) -> Result<String, ZoomError> {
if let Some(uri) = &self.input_uri {
Ok(uri.clone())
} else {
println!("Enter an URL or a path to a tiles.yaml file: ");
stdin_line()
}
}
#[must_use]
pub fn is_bulk_mode(&self) -> bool {
self.bulk.is_some()
}
#[must_use]
pub fn output_file(&self) -> Option<PathBuf> {
self.outfile_option.clone().or_else(|| self.outfile.clone())
}
#[must_use]
pub fn bulk_output_file(&self) -> Option<PathBuf> {
self.output_file()
}
#[must_use]
pub fn request_referer(&self) -> Option<&str> {
let candidate = if self.is_bulk_mode() {
self.bulk.as_deref()
} else {
self.input_uri.as_deref()
};
candidate.filter(|uri| uri.starts_with("http://") || uri.starts_with("https://"))
}
#[must_use]
pub fn should_use_largest(&self) -> bool {
self.largest
|| (self.is_bulk_mode()
&& self.max_width.is_none()
&& self.max_height.is_none()
&& self.zoom_level.is_none())
}
#[must_use]
pub fn has_level_specifying_args(&self) -> bool {
self.max_width.is_some() || self.max_height.is_some() || self.zoom_level.is_some()
}
/// Finds the dezoomer selected by the command-line arguments.
///
/// # Errors
///
/// Returns [`ZoomError::NoSuchDezoomer`] if the configured name is unknown.
pub fn find_dezoomer(&self) -> Result<Box<dyn Dezoomer>, ZoomError> {
auto::all_dezoomers(true)
.into_iter()
.find(|d| d.name() == self.dezoomer)
.ok_or_else(|| ZoomError::NoSuchDezoomer {
name: self.dezoomer.clone(),
})
}
pub fn best_size<I: Iterator<Item = Vec2d>>(&self, sizes: I) -> Option<Vec2d> {
if self.should_use_largest() {
sizes.max_by_key(|s| s.area())
} else if self.max_width.is_some() || self.max_height.is_some() {
sizes
.filter(|s| {
self.max_width.is_none_or(|w| s.x <= w)
&& self.max_height.is_none_or(|h| s.y <= h)
})
.max_by_key(|s| s.area())
} else {
None
}
}
pub fn headers(&self) -> impl Iterator<Item = (&String, &String)> {
self.headers.iter().map(|(k, v)| (k, v))
}
}
fn parse_header(s: &str) -> Result<(String, String), &'static str> {
let vals: Vec<&str> = s.splitn(2, ':').map(str::trim).collect();
if let [key, value] = vals[..] {
Ok((key.into(), value.into()))
} else {
Err("Invalid header format. Expected 'Name: Value'")
}
}
fn parse_duration(s: &str) -> Result<Duration, &'static str> {
let err_msg = "Invalid duration. \
A duration is a number followed by a unit, such as '10ms' or '5s'";
let re = Regex::new(r"^(\d+)\s*(min|s|ms|ns)$").unwrap();
let caps = re.captures(s).ok_or(err_msg)?;
let val: u64 = caps[1].parse().map_err(|_| err_msg)?;
match &caps[2] {
"h" => Ok(Duration::from_secs(3600 * val)),
"min" | "m" => Ok(Duration::from_secs(60 * val)),
"s" => Ok(Duration::from_secs(val)),
"ms" => Ok(Duration::from_millis(val)),
"ns" => Ok(Duration::from_nanos(val)),
_ => Err(err_msg),
}
}
#[test]
fn test_headers_and_input() {
let args = Arguments::parse_from([
"dezoomify-rs",
//
"--header",
"Referer: http://test.com",
//
"--header",
"User-Agent: custom",
//
"--header",
"A:B",
//
"input-url",
]);
assert_eq!(args.input_uri, Some("input-url".into()));
assert_eq!(
args.headers,
vec![
("Referer".into(), "http://test.com".into()),
("User-Agent".into(), "custom".into()),
("A".into(), "B".into()),
]
);
}
#[test]
fn test_parse_duration() {
assert_eq!(parse_duration("2s"), Ok(Duration::from_secs(2)));
assert_eq!(parse_duration("29 s"), Ok(Duration::from_secs(29)));
assert_eq!(parse_duration("2min"), Ok(Duration::from_mins(2)));
assert_eq!(parse_duration("1000 ms"), Ok(Duration::from_secs(1)));
assert!(parse_duration("1 2 ms").is_err());
assert!(parse_duration("1 s s").is_err());
assert!(parse_duration("ms").is_err());
assert!(parse_duration("1j").is_err());
assert!(parse_duration("").is_err());
}
#[test]
fn test_bulk_url_reading() {
// Test bulk mode detection
let mut args = Arguments {
bulk: Some("dummy_bulk.txt".into()), // Path needs to be Some for is_bulk_mode
..Default::default()
};
assert!(args.is_bulk_mode());
// Test should_use_largest in bulk mode
assert!(args.should_use_largest());
// Test should_use_largest with explicit options
args.max_width = Some(1000);
assert!(!args.should_use_largest());
}
#[test]
fn test_outfile_option_parsing() {
let args = Arguments::parse_from([
"dezoomify-rs",
"https://example.com/info.json",
"--outfile",
"out.jpg",
]);
assert_eq!(args.output_file(), Some(PathBuf::from("out.jpg")));
}
#[test]
fn test_request_referer_prefers_bulk_source_in_bulk_mode() {
let args = Arguments::parse_from([
"dezoomify-rs",
"--bulk",
"urls.txt",
"not-a-referer.jpg",
"--outfile",
"out.jpg",
]);
assert_eq!(args.request_referer(), None);
}
#[test]
fn test_request_referer_uses_http_bulk_source() {
let args = Arguments::parse_from([
"dezoomify-rs",
"--bulk",
"https://example.com/manifest.json",
]);
assert_eq!(
args.request_referer(),
Some("https://example.com/manifest.json")
);
}
#[test]
fn test_should_use_largest() {
let mut args = Arguments::default();
// Normal mode without bulk
assert!(!args.should_use_largest());
// With explicit largest flag
args.largest = true;
assert!(args.should_use_largest());
// Reset and test bulk mode
args.largest = false;
args.bulk = Some("test.txt".into());
assert!(args.should_use_largest()); // Should be true in bulk mode without level options
// With level options in bulk mode
args.zoom_level = Some(1);
assert!(!args.should_use_largest());
}