Skip to main content

document_svg/document/
cbz.rs

1//! Bounded Comic Book Archive (CBZ) reader.
2//!
3//! CBZ is a ZIP archive convention rather than a standalone page format. This
4//! importer orders image members by a case-insensitive natural filename sort
5//! and emits one SVG page for each embedded PNG or JPEG. It reads entries in
6//! memory and never extracts archive paths to the filesystem.
7
8use 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}