document_svg/document/
cbz.rs1use std::cmp::Ordering;
9use std::fs::File;
10use std::io::{BufReader, Read};
11use std::path::Path;
12
13use base64::Engine;
14use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
15use zip::ZipArchive;
16
17use crate::convert::{ConvertOptions, PageConsumer};
18use crate::error::{Error, Result};
19use crate::ir::{IDENTITY, Node, Page, SourceMeta};
20use crate::ooxml::sniff_image_mime;
21
22const MAX_CBZ_ARCHIVE_ENTRIES: usize = 100_000;
23const MAX_CBZ_IMAGE_NAME_BYTES: usize = 8 * 1024 * 1024;
24const MAX_CBZ_PAGE_PIXELS: u64 = 40_000_000;
25const MAX_CBZ_TOTAL_PIXELS: u64 = 100_000_000;
26const MAX_CBZ_PAGE_BYTES: u64 = 96 * 1024 * 1024;
27const MAX_CBZ_TOTAL_IMAGE_BYTES: u64 = 384 * 1024 * 1024;
28const MAX_CBZ_TOTAL_DATA_URI_BYTES: u64 = 512 * 1024 * 1024;
29const MAX_CBZ_PAGE_DIMENSION: f64 = 1_000_000.0;
30
31struct ImageEntry {
32 index: usize,
33 name: String,
34 size: u64,
35}
36
37pub(crate) fn convert(
38 path: &Path,
39 options: &ConvertOptions,
40 sink: &mut dyn PageConsumer,
41) -> Result<Vec<String>> {
42 let mut archive = ZipArchive::new(BufReader::new(File::open(path)?))?;
43 if archive.len() > MAX_CBZ_ARCHIVE_ENTRIES {
44 return Err(Error::LimitExceeded(format!(
45 "CBZ archive contains {} entries; maximum is {MAX_CBZ_ARCHIVE_ENTRIES}",
46 archive.len()
47 )));
48 }
49 let mut image_entries = Vec::new();
50 let mut total_image_name_bytes = 0usize;
51 for index in 0..archive.len() {
52 let entry = archive.by_index(index)?;
53 if entry.is_dir() || entry.is_symlink() || !is_comic_image_filename(entry.name()) {
54 continue;
55 }
56 total_image_name_bytes = total_image_name_bytes
57 .checked_add(entry.name().len())
58 .ok_or_else(|| Error::LimitExceeded("CBZ image name size overflowed".into()))?;
59 if total_image_name_bytes > MAX_CBZ_IMAGE_NAME_BYTES {
60 return Err(Error::LimitExceeded(format!(
61 "CBZ image member names exceed {MAX_CBZ_IMAGE_NAME_BYTES} bytes"
62 )));
63 }
64 image_entries.push(ImageEntry {
65 index,
66 name: entry.name().to_owned(),
67 size: entry.size(),
68 });
69 if image_entries.len() > options.max_pages {
70 return Err(Error::LimitExceeded(format!(
71 "CBZ contains more than {} image members",
72 options.max_pages
73 )));
74 }
75 }
76 image_entries.sort_by(|left, right| {
77 natural_cmp(&left.name, &right.name).then_with(|| left.name.cmp(&right.name))
78 });
79 if image_entries.is_empty() {
80 return Err(Error::InvalidInput(
81 "CBZ archive contains no PNG or JPEG page images".into(),
82 ));
83 }
84
85 let page_limit = options.max_zip_entry_bytes.min(MAX_CBZ_PAGE_BYTES);
86 let total_input_limit = options.max_input_bytes.min(MAX_CBZ_TOTAL_IMAGE_BYTES);
87 let mut total_image_bytes = 0u64;
88 let mut total_data_uri_bytes = 0u64;
89 let mut total_pixels = 0u64;
90 let mut unsupported_image_count = 0usize;
91 let mut invalid_image_count = 0usize;
92 let mut page_number = 0usize;
93 for image_entry in image_entries {
94 if image_entry.size > page_limit {
95 return Err(Error::LimitExceeded(format!(
96 "CBZ image '{}' is {} bytes; maximum is {page_limit}",
97 image_entry.name, image_entry.size
98 )));
99 }
100 let mut entry = archive.by_index(image_entry.index)?;
101 let capacity = usize::try_from(image_entry.size).map_err(|_| {
102 Error::LimitExceeded(format!(
103 "CBZ image '{}' does not fit in memory",
104 image_entry.name
105 ))
106 })?;
107 let mut bytes = Vec::with_capacity(capacity.min(8 * 1024 * 1024));
108 entry
109 .by_ref()
110 .take(page_limit.saturating_add(1))
111 .read_to_end(&mut bytes)?;
112 if bytes.len() as u64 > page_limit {
113 return Err(Error::LimitExceeded(format!(
114 "CBZ image '{}' expanded beyond {page_limit} bytes",
115 image_entry.name
116 )));
117 }
118 total_image_bytes = total_image_bytes
119 .checked_add(bytes.len() as u64)
120 .ok_or_else(|| Error::LimitExceeded("CBZ image byte count overflowed".into()))?;
121 if total_image_bytes > total_input_limit {
122 return Err(Error::LimitExceeded(format!(
123 "CBZ image data exceeds {total_input_limit} bytes"
124 )));
125 }
126
127 let mime = match sniff_image_mime(&bytes) {
128 Some("image/png") => "image/png",
129 Some("image/jpeg") => "image/jpeg",
130 Some(_) => {
131 unsupported_image_count += 1;
132 continue;
133 }
134 None => {
135 invalid_image_count += 1;
136 continue;
137 }
138 };
139 let (width, height) = match image_dimensions(&bytes, mime) {
140 Ok(dimensions) => dimensions,
141 Err(_) => {
142 invalid_image_count += 1;
143 continue;
144 }
145 };
146 let pixels = u64::from(width)
147 .checked_mul(u64::from(height))
148 .ok_or_else(|| Error::LimitExceeded("CBZ image pixel count overflowed".into()))?;
149 if width == 0 || height == 0 || pixels > MAX_CBZ_PAGE_PIXELS {
150 return Err(Error::LimitExceeded(format!(
151 "CBZ image '{}' is {width}x{height}; maximum is {MAX_CBZ_PAGE_PIXELS} pixels",
152 image_entry.name
153 )));
154 }
155 total_pixels = total_pixels
156 .checked_add(pixels)
157 .ok_or_else(|| Error::LimitExceeded("CBZ total pixel count overflowed".into()))?;
158 if total_pixels > MAX_CBZ_TOTAL_PIXELS {
159 return Err(Error::LimitExceeded(format!(
160 "CBZ images exceed the {MAX_CBZ_TOTAL_PIXELS}-pixel total limit"
161 )));
162 }
163 let data_uri = format!("data:{mime};base64,{}", BASE64_STANDARD.encode(bytes));
164 total_data_uri_bytes = total_data_uri_bytes
165 .checked_add(data_uri.len() as u64)
166 .ok_or_else(|| Error::LimitExceeded("CBZ data URI byte count overflowed".into()))?;
167 if total_data_uri_bytes > MAX_CBZ_TOTAL_DATA_URI_BYTES {
168 return Err(Error::LimitExceeded(format!(
169 "CBZ embedded image data exceeds {MAX_CBZ_TOTAL_DATA_URI_BYTES} bytes"
170 )));
171 }
172 let width_points = f64::from(width);
173 let height_points = f64::from(height);
174 if width_points > MAX_CBZ_PAGE_DIMENSION || height_points > MAX_CBZ_PAGE_DIMENSION {
175 return Err(Error::LimitExceeded(format!(
176 "CBZ image '{}' page dimensions exceed the supported range",
177 image_entry.name
178 )));
179 }
180
181 page_number += 1;
182 let mut page = Page::new(page_number, width_points, height_points, "cbz");
183 page.title = format!("Comic page {page_number}");
184 page.description = format!("CBZ image page {page_number}");
185 page.nodes.push(Node::Image {
186 id: format!("cbz-image-{page_number}"),
187 href: data_uri,
188 x: 0.0,
189 y: 0.0,
190 width: width_points,
191 height: height_points,
192 transform: IDENTITY,
193 opacity: 1.0,
194 clip_id: None,
195 meta: SourceMeta {
196 semantic_role: "cbz:image".into(),
197 alt_text: format!("Comic page {page_number}"),
198 ..Default::default()
199 },
200 });
201 sink.consume(page)?;
202 }
203
204 if page_number == 0 {
205 return Err(Error::InvalidInput(
206 "CBZ archive has no valid PNG or JPEG pages; unsupported or malformed image entries may have been present".into(),
207 ));
208 }
209 let mut warnings = Vec::new();
210 if unsupported_image_count > 0 {
211 warnings.push(format!(
212 "CBZ omitted {unsupported_image_count} image member(s) with formats other than PNG/JPEG"
213 ));
214 }
215 if invalid_image_count > 0 {
216 warnings.push(format!(
217 "CBZ omitted {invalid_image_count} malformed or uninspectable image member(s)"
218 ));
219 }
220 Ok(warnings)
221}
222
223fn is_comic_image_filename(name: &str) -> bool {
224 let extension = name.rsplit_once('.').map(|(_, extension)| extension);
225 extension.is_some_and(|extension| {
226 matches!(
227 extension.to_ascii_lowercase().as_str(),
228 "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff"
229 )
230 })
231}
232
233fn image_dimensions(bytes: &[u8], mime: &str) -> Result<(u32, u32)> {
234 match mime {
235 "image/png" => {
236 let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
237 let reader = decoder
238 .read_info()
239 .map_err(|error| Error::InvalidInput(format!("invalid PNG header: {error}")))?;
240 Ok((reader.info().width, reader.info().height))
241 }
242 "image/jpeg" => {
243 let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(bytes));
244 decoder
245 .read_info()
246 .map_err(|error| Error::InvalidInput(format!("invalid JPEG header: {error}")))?;
247 let info = decoder
248 .info()
249 .ok_or_else(|| Error::InvalidInput("JPEG image metadata is missing".into()))?;
250 Ok((u32::from(info.width), u32::from(info.height)))
251 }
252 other => Err(Error::Unsupported(format!(
253 "unsupported CBZ image MIME type '{other}'"
254 ))),
255 }
256}
257
258fn natural_cmp(left: &str, right: &str) -> Ordering {
259 let left_bytes = left.as_bytes();
260 let right_bytes = right.as_bytes();
261 let (mut left_index, mut right_index) = (0usize, 0usize);
262 while left_index < left_bytes.len() && right_index < right_bytes.len() {
263 if left_bytes[left_index].is_ascii_digit() && right_bytes[right_index].is_ascii_digit() {
264 let left_start = left_index;
265 let right_start = right_index;
266 while left_index < left_bytes.len() && left_bytes[left_index].is_ascii_digit() {
267 left_index += 1;
268 }
269 while right_index < right_bytes.len() && right_bytes[right_index].is_ascii_digit() {
270 right_index += 1;
271 }
272 let left_significant = significant_digits(&left_bytes[left_start..left_index]);
273 let right_significant = significant_digits(&right_bytes[right_start..right_index]);
274 let numeric_order = left_significant
275 .len()
276 .cmp(&right_significant.len())
277 .then_with(|| left_significant.cmp(right_significant));
278 if numeric_order != Ordering::Equal {
279 return numeric_order;
280 }
281 let leading_zero_order = (left_index - left_start).cmp(&(right_index - right_start));
282 if leading_zero_order != Ordering::Equal {
283 return leading_zero_order;
284 }
285 continue;
286 }
287 let character_order = left_bytes[left_index]
288 .to_ascii_lowercase()
289 .cmp(&right_bytes[right_index].to_ascii_lowercase());
290 if character_order != Ordering::Equal {
291 return character_order;
292 }
293 left_index += 1;
294 right_index += 1;
295 }
296 left_bytes
297 .len()
298 .cmp(&right_bytes.len())
299 .then_with(|| left.cmp(right))
300}
301
302fn significant_digits(digits: &[u8]) -> &[u8] {
303 let first_nonzero = digits.iter().position(|digit| *digit != b'0');
304 first_nonzero.map_or_else(
305 || &digits[digits.len().saturating_sub(1)..],
306 |index| &digits[index..],
307 )
308}
309
310#[cfg(test)]
311mod tests {
312 use super::natural_cmp;
313 use std::cmp::Ordering;
314
315 #[test]
316 fn sorts_numeric_page_names_in_natural_order() {
317 let mut names = ["page10.png", "page2.png", "Page 1.png", "page01.png"];
318 names.sort_by(|left, right| natural_cmp(left, right));
319 assert_eq!(
320 names,
321 ["Page 1.png", "page01.png", "page2.png", "page10.png"]
322 );
323 assert_eq!(natural_cmp("2.jpg", "10.jpg"), Ordering::Less);
324 }
325}