Skip to main content

document_svg/document/
fb2.rs

1//! Bounded FictionBook 2 (`.fb2`) e-book preview.
2//!
3//! FictionBook is one XML document containing a description, one or more
4//! bodies, and optional base64 `<binary>` resources. This reader renders the
5//! first main body as safe flowing SVG blocks and resolves only validated
6//! embedded PNG/JPEG images. It never follows links, evaluates markup, or
7//! executes any book content.
8
9use std::collections::HashMap;
10use std::fs::File;
11use std::io::{BufReader, Read};
12use std::path::Path;
13
14use base64::Engine;
15use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
16use quick_xml::Reader;
17use quick_xml::events::Event;
18
19use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
20use crate::document::html::{HtmlBlock, InlineHtmlImage, render_blocks_to_pages_with_warnings};
21use crate::error::{Error, Result};
22use crate::ooxml::{local_name, sniff_image_mime};
23use crate::table::{TableAlign, TableData};
24
25const MAX_FB2_INPUT_BYTES: u64 = 128 * 1024 * 1024;
26const MAX_FB2_XML_DEPTH: usize = 256;
27const MAX_FB2_BINARY_BYTES: usize = 8 * 1024 * 1024;
28const MAX_FB2_TOTAL_BINARY_BYTES: usize = 32 * 1024 * 1024;
29const MAX_FB2_TOTAL_URI_BYTES: usize = 48 * 1024 * 1024;
30const MAX_FB2_IMAGE_PIXELS: u64 = 40_000_000;
31const MAX_FB2_TOTAL_IMAGE_PIXELS: u64 = 100_000_000;
32const MAX_FB2_IMAGES: usize = 10_000;
33const MAX_FB2_TEXT_BYTES: usize = 64 * 1024 * 1024;
34
35#[derive(Default)]
36struct ImageBudget {
37    decoded_bytes: usize,
38    uri_bytes: usize,
39    pixels: u64,
40    count: usize,
41}
42
43#[derive(Default)]
44struct BinaryBuilder {
45    id: String,
46    content_type: String,
47    text: String,
48}
49
50#[derive(Default)]
51struct Metadata {
52    title: String,
53    author: String,
54}
55
56#[derive(Default)]
57struct TableBuilder {
58    rows: Vec<Vec<String>>,
59    current_row: Vec<String>,
60    in_cell: bool,
61}
62
63pub(crate) fn looks_like_prefix(bytes: &[u8]) -> bool {
64    let text = String::from_utf8_lossy(bytes);
65    let trimmed = text.trim_start_matches('\u{feff}').trim_start();
66    trimmed.contains("<FictionBook")
67        || trimmed.contains("<fictionbook")
68        || (trimmed.contains("FictionBook") && trimmed.contains("fictionbook/2.0"))
69        || trimmed.contains("http://www.gribuser.ru/xml/fictionbook/2.0")
70}
71
72pub(crate) fn convert(
73    path: &Path,
74    options: &ConvertOptions,
75    sink: &mut dyn PageConsumer,
76) -> Result<Vec<String>> {
77    if path
78        .file_name()
79        .and_then(|name| name.to_str())
80        .is_some_and(|name| name.to_ascii_lowercase().ends_with(".fb2.zip"))
81    {
82        let metadata = std::fs::metadata(path)?;
83        let input_limit = options.max_input_bytes.min(MAX_FB2_INPUT_BYTES);
84        if metadata.len() > input_limit {
85            return Err(Error::LimitExceeded(format!(
86                "FictionBook ZIP input exceeds maximum bytes ({input_limit})"
87            )));
88        }
89        let mut archive =
90            zip::ZipArchive::new(BufReader::new(File::open(path)?)).map_err(|error| {
91                Error::InvalidInput(format!("invalid FictionBook ZIP archive: {error}"))
92            })?;
93        if archive.len() > 10_000 {
94            return Err(Error::LimitExceeded(
95                "FictionBook ZIP archive contains more than 10,000 entries".into(),
96            ));
97        }
98        let mut selected = None;
99        for index in 0..archive.len() {
100            let entry = archive.by_index(index)?;
101            let name = entry.name();
102            if name.len() > 4096
103                || name.starts_with('/')
104                || name.contains('\\')
105                || name.split('/').any(|part| part == "..")
106            {
107                return Err(Error::InvalidInput(
108                    "FictionBook ZIP contains an unsafe entry name".into(),
109                ));
110            }
111            if !entry.is_dir() && name.to_ascii_lowercase().ends_with(".fb2") {
112                if selected.is_some() {
113                    return Err(Error::InvalidInput(
114                        "FictionBook ZIP must contain exactly one FB2 document".into(),
115                    ));
116                }
117                selected = Some(index);
118            }
119        }
120        let index = selected.ok_or_else(|| {
121            Error::InvalidInput("FictionBook ZIP contains no .fb2 document".into())
122        })?;
123        let mut entry = archive.by_index(index)?;
124        if entry.size() > input_limit {
125            return Err(Error::LimitExceeded(format!(
126                "FictionBook ZIP document exceeds maximum bytes ({input_limit})"
127            )));
128        }
129        let mut bytes = Vec::new();
130        Read::take(&mut entry, input_limit.saturating_add(1)).read_to_end(&mut bytes)?;
131        if bytes.len() as u64 > input_limit {
132            return Err(Error::LimitExceeded(format!(
133                "FictionBook ZIP document exceeds maximum bytes ({input_limit})"
134            )));
135        }
136        let xml = String::from_utf8(bytes).map_err(|error| {
137            Error::InvalidInput(format!("FictionBook ZIP document is not UTF-8: {error}"))
138        })?;
139        return convert_source(&xml, options, sink);
140    }
141    let bytes = read_limited_file(
142        path,
143        options.max_input_bytes.min(MAX_FB2_INPUT_BYTES),
144        "FictionBook input",
145    )?;
146    let xml = String::from_utf8(bytes)
147        .map_err(|error| Error::InvalidInput(format!("FictionBook input is not UTF-8: {error}")))?;
148    convert_source(&xml, options, sink)
149}
150
151fn convert_source(
152    xml: &str,
153    options: &ConvertOptions,
154    sink: &mut dyn PageConsumer,
155) -> Result<Vec<String>> {
156    let (images, mut warnings) = collect_binaries(xml, options.max_xml_events)?;
157    let (blocks, parser_warnings) = parse_body(xml, &images, options.max_xml_events)?;
158    warnings.extend(parser_warnings);
159    if blocks.is_empty() {
160        return Err(Error::InvalidInput(
161            "FictionBook contains no renderable main-body content".into(),
162        ));
163    }
164    render_blocks_to_pages_with_warnings(&blocks, sink, options, &warnings)?;
165    Ok(warnings)
166}
167
168fn collect_binaries(
169    xml: &str,
170    max_events: usize,
171) -> Result<(HashMap<String, InlineHtmlImage>, Vec<String>)> {
172    let mut reader = Reader::from_str(xml);
173    reader.config_mut().trim_text(false);
174    let mut buffer = Vec::new();
175    let mut current = None::<BinaryBuilder>;
176    let mut depth = 0usize;
177    let mut events = 0usize;
178    let mut budget = ImageBudget::default();
179    let mut images = HashMap::new();
180    let mut warnings = Vec::new();
181    loop {
182        events = events.saturating_add(1);
183        if events > max_events {
184            return Err(Error::LimitExceeded(format!(
185                "FictionBook XML exceeds {max_events} parser events"
186            )));
187        }
188        match reader.read_event_into(&mut buffer)? {
189            Event::Start(element) => {
190                let qualified_name = element.name();
191                let name = local_name(qualified_name.as_ref());
192                if name == b"binary" {
193                    let id = attribute(&element, b"id").unwrap_or_default();
194                    let content_type = attribute(&element, b"content-type").unwrap_or_default();
195                    if id.len() > 4096 || id.is_empty() {
196                        return Err(Error::InvalidInput(
197                            "FictionBook binary id is missing or overlong".into(),
198                        ));
199                    }
200                    current = Some(BinaryBuilder {
201                        id,
202                        content_type,
203                        text: String::new(),
204                    });
205                }
206                depth = depth.saturating_add(1);
207                if depth > MAX_FB2_XML_DEPTH {
208                    return Err(Error::LimitExceeded(format!(
209                        "FictionBook XML nesting exceeds {MAX_FB2_XML_DEPTH}"
210                    )));
211                }
212            }
213            Event::Empty(element) => {
214                if local_name(element.name().as_ref()) == b"binary" {
215                    push_warning_once(
216                        &mut warnings,
217                        "empty FictionBook binary resources were omitted",
218                    );
219                }
220            }
221            Event::Text(text) => {
222                if let Some(binary) = current.as_mut() {
223                    let value = String::from_utf8_lossy(text.as_ref());
224                    if binary.text.len().saturating_add(value.len()) > MAX_FB2_TOTAL_BINARY_BYTES {
225                        return Err(Error::LimitExceeded(format!(
226                            "FictionBook binary data exceeds {MAX_FB2_TOTAL_BINARY_BYTES} bytes"
227                        )));
228                    }
229                    binary.text.push_str(&value);
230                }
231            }
232            Event::CData(text) => {
233                if let Some(binary) = current.as_mut() {
234                    let value = String::from_utf8_lossy(text.as_ref());
235                    if binary.text.len().saturating_add(value.len()) > MAX_FB2_TOTAL_BINARY_BYTES {
236                        return Err(Error::LimitExceeded(format!(
237                            "FictionBook binary data exceeds {MAX_FB2_TOTAL_BINARY_BYTES} bytes"
238                        )));
239                    }
240                    binary.text.push_str(&value);
241                }
242            }
243            Event::End(element) => {
244                let qualified_name = element.name();
245                let name = local_name(qualified_name.as_ref());
246                if name == b"binary"
247                    && let Some(binary) = current.take()
248                    && let Some(image) = decode_binary(&binary, &mut budget, &mut warnings)?
249                {
250                    images.insert(binary.id, image);
251                }
252                depth = depth.saturating_sub(1);
253            }
254            Event::DocType(_) => {
255                return Err(Error::InvalidInput(
256                    "FictionBook document type declarations are not supported".into(),
257                ));
258            }
259            Event::Eof => break,
260            _ => {}
261        }
262        buffer.clear();
263    }
264    Ok((images, warnings))
265}
266
267fn decode_binary(
268    binary: &BinaryBuilder,
269    budget: &mut ImageBudget,
270    warnings: &mut Vec<String>,
271) -> Result<Option<InlineHtmlImage>> {
272    let compact: String = binary
273        .text
274        .chars()
275        .filter(|character| !character.is_ascii_whitespace())
276        .collect();
277    if compact.len() > MAX_FB2_BINARY_BYTES.saturating_mul(2) {
278        push_warning_once(
279            warnings,
280            "FictionBook binary image exceeded the base64 size limit",
281        );
282        return Ok(None);
283    }
284    let bytes = match BASE64_STANDARD.decode(compact.as_bytes()) {
285        Ok(bytes) => bytes,
286        Err(_) => {
287            push_warning_once(
288                warnings,
289                "malformed FictionBook binary resources were omitted",
290            );
291            return Ok(None);
292        }
293    };
294    if bytes.len() > MAX_FB2_BINARY_BYTES {
295        push_warning_once(
296            warnings,
297            "FictionBook binary image exceeded the per-image byte limit",
298        );
299        return Ok(None);
300    }
301    let Some(mime) =
302        sniff_image_mime(&bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
303    else {
304        push_warning_once(
305            warnings,
306            "unsupported FictionBook binary resources were omitted; only PNG and JPEG are embedded",
307        );
308        return Ok(None);
309    };
310    if !binary.content_type.is_empty()
311        && !binary.content_type.to_ascii_lowercase().starts_with(mime)
312    {
313        push_warning_once(
314            warnings,
315            "FictionBook binary content type did not match its image signature",
316        );
317        return Ok(None);
318    }
319    let Some((width, height)) = crate::document::mhtml::image_dimensions(&bytes, mime) else {
320        push_warning_once(
321            warnings,
322            "invalid FictionBook PNG/JPEG image dimensions were omitted",
323        );
324        return Ok(None);
325    };
326    let pixels = u64::from(width).saturating_mul(u64::from(height));
327    if width == 0 || height == 0 || pixels > MAX_FB2_IMAGE_PIXELS {
328        push_warning_once(
329            warnings,
330            "FictionBook binary image exceeded the per-image pixel limit",
331        );
332        return Ok(None);
333    }
334    let uri_bytes = format!("data:{mime};base64,").len() + bytes.len().div_ceil(3) * 4;
335    let next_bytes = budget.decoded_bytes.saturating_add(bytes.len());
336    let next_uri = budget.uri_bytes.saturating_add(uri_bytes);
337    let next_pixels = budget.pixels.saturating_add(pixels);
338    if next_bytes > MAX_FB2_TOTAL_BINARY_BYTES
339        || next_uri > MAX_FB2_TOTAL_URI_BYTES
340        || next_pixels > MAX_FB2_TOTAL_IMAGE_PIXELS
341        || budget.count >= MAX_FB2_IMAGES
342    {
343        push_warning_once(
344            warnings,
345            "FictionBook images exceeded cumulative resource limits",
346        );
347        return Ok(None);
348    }
349    budget.decoded_bytes = next_bytes;
350    budget.uri_bytes = next_uri;
351    budget.pixels = next_pixels;
352    budget.count += 1;
353    let prefix = format!("data:{mime};base64,");
354    Ok(Some(InlineHtmlImage {
355        href: format!("{prefix}{}", BASE64_STANDARD.encode(bytes)),
356        pixel_width: width,
357        pixel_height: height,
358    }))
359}
360
361fn parse_body(
362    xml: &str,
363    images: &HashMap<String, InlineHtmlImage>,
364    max_events: usize,
365) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
366    let mut reader = Reader::from_str(xml);
367    reader.config_mut().trim_text(false);
368    let mut buffer = Vec::new();
369    let mut blocks = Vec::new();
370    let mut warnings = Vec::new();
371    let mut depth = 0usize;
372    let mut events = 0usize;
373    let mut main_body = false;
374    let mut body_seen = false;
375    let mut ignored_body_depth = 0usize;
376    let mut section_depth = 0usize;
377    let mut in_title = false;
378    let mut title_text = String::new();
379    let mut paragraph: Option<String> = None;
380    let mut cell = String::new();
381    let mut table = None::<TableBuilder>;
382    let mut rendered_text_bytes = 0usize;
383    let metadata = extract_metadata(xml, max_events)?;
384    if !metadata.title.is_empty() {
385        blocks.push(HtmlBlock::Heading {
386            level: 1,
387            text: metadata.title,
388        });
389    }
390    if !metadata.author.is_empty() {
391        blocks.push(HtmlBlock::Paragraph {
392            text: format!("Author: {}", metadata.author),
393        });
394    }
395    loop {
396        events = events.saturating_add(1);
397        if events > max_events {
398            return Err(Error::LimitExceeded(format!(
399                "FictionBook body exceeds {max_events} parser events"
400            )));
401        }
402        match reader.read_event_into(&mut buffer)? {
403            Event::Start(element) => {
404                let qualified_name = element.name();
405                let name = local_name(qualified_name.as_ref());
406                if name == b"body" {
407                    if body_seen {
408                        ignored_body_depth = 1;
409                    } else if attribute(&element, b"name").is_none() {
410                        main_body = true;
411                        body_seen = true;
412                    } else {
413                        ignored_body_depth = 1;
414                        body_seen = true;
415                    }
416                } else if ignored_body_depth > 0 {
417                    ignored_body_depth += 1;
418                } else if main_body {
419                    match name {
420                        b"section" => section_depth = section_depth.saturating_add(1),
421                        b"title" => {
422                            flush_paragraph(&mut blocks, &mut paragraph, &mut rendered_text_bytes)?;
423                            in_title = true;
424                            title_text.clear();
425                        }
426                        b"p" | b"subtitle" | b"v" => {
427                            if paragraph.is_none() {
428                                paragraph = Some(String::new());
429                            }
430                        }
431                        b"table" => table = Some(TableBuilder::default()),
432                        b"th" | b"td" => {
433                            cell.clear();
434                            if let Some(table) = table.as_mut() {
435                                table.in_cell = true;
436                            }
437                        }
438                        _ => {}
439                    }
440                }
441                depth = depth.saturating_add(1);
442                if depth > MAX_FB2_XML_DEPTH {
443                    return Err(Error::LimitExceeded(format!(
444                        "FictionBook XML nesting exceeds {MAX_FB2_XML_DEPTH}"
445                    )));
446                }
447            }
448            Event::Empty(element) => {
449                if ignored_body_depth == 0 && main_body {
450                    let qualified_name = element.name();
451                    let name = local_name(qualified_name.as_ref());
452                    match name {
453                        b"empty-line" => {
454                            flush_paragraph(&mut blocks, &mut paragraph, &mut rendered_text_bytes)?
455                        }
456                        b"image" => {
457                            if let Some(reference) = attribute(&element, b"href")
458                                .or_else(|| attribute(&element, b"l:href"))
459                            {
460                                append_image(
461                                    &mut blocks,
462                                    &mut warnings,
463                                    images,
464                                    &reference,
465                                    &mut paragraph,
466                                    &mut cell,
467                                    table.as_ref().is_some_and(|table| table.in_cell),
468                                    &mut rendered_text_bytes,
469                                )?;
470                            } else {
471                                push_warning_once(
472                                    &mut warnings,
473                                    "FictionBook image without xlink:href was omitted",
474                                );
475                            }
476                        }
477                        b"br" => {
478                            if let Some(text) = paragraph.as_mut() {
479                                text.push('\n');
480                            }
481                        }
482                        _ => {}
483                    }
484                }
485            }
486            Event::Text(text) => {
487                if ignored_body_depth == 0 && main_body {
488                    let decoded = String::from_utf8_lossy(text.as_ref());
489                    let decoded = quick_xml::escape::unescape(&decoded).map_err(|error| {
490                        Error::InvalidInput(format!("invalid FictionBook XML text: {error}"))
491                    })?;
492                    if in_title {
493                        title_text.push_str(&decoded);
494                    } else if let Some(table) = table.as_ref() {
495                        if table.in_cell {
496                            cell.push_str(&decoded);
497                        } else if let Some(text) = paragraph.as_mut() {
498                            text.push_str(&decoded);
499                        }
500                    } else if let Some(text) = paragraph.as_mut() {
501                        text.push_str(&decoded);
502                    }
503                }
504            }
505            Event::CData(text) => {
506                if ignored_body_depth == 0 && main_body {
507                    let decoded = String::from_utf8_lossy(text.as_ref());
508                    if in_title {
509                        title_text.push_str(&decoded);
510                    } else if let Some(table) = table.as_ref() {
511                        if table.in_cell {
512                            cell.push_str(&decoded);
513                        } else if let Some(text) = paragraph.as_mut() {
514                            text.push_str(&decoded);
515                        }
516                    } else if let Some(text) = paragraph.as_mut() {
517                        text.push_str(&decoded);
518                    }
519                }
520            }
521            Event::GeneralRef(reference) => {
522                if ignored_body_depth == 0 && main_body {
523                    let value = reference.decode().map_err(|error| {
524                        Error::InvalidInput(format!("invalid FictionBook reference: {error}"))
525                    })?;
526                    if let Some(text) = paragraph.as_mut() {
527                        text.push_str(&format!("&{value};"));
528                    }
529                }
530            }
531            Event::End(element) => {
532                let qualified_name = element.name();
533                let name = local_name(qualified_name.as_ref());
534                if ignored_body_depth > 0 {
535                    ignored_body_depth = ignored_body_depth.saturating_sub(1);
536                } else if name == b"body" {
537                    main_body = false;
538                } else if main_body {
539                    match name {
540                        b"p" | b"subtitle" | b"v" => {
541                            if !in_title && table.as_ref().is_none_or(|table| !table.in_cell) {
542                                flush_paragraph(
543                                    &mut blocks,
544                                    &mut paragraph,
545                                    &mut rendered_text_bytes,
546                                )?;
547                            }
548                        }
549                        b"title" => {
550                            let title = clean_fb2_text(&title_text);
551                            if !title.is_empty() {
552                                blocks.push(HtmlBlock::Heading {
553                                    level: section_depth.clamp(1, 6) as u8,
554                                    text: title,
555                                });
556                            }
557                            in_title = false;
558                            title_text.clear();
559                        }
560                        b"section" => section_depth = section_depth.saturating_sub(1),
561                        b"th" | b"td" => {
562                            if let Some(table) = table.as_mut() {
563                                table.current_row.push(clean_fb2_text(&cell));
564                                table.in_cell = false;
565                            }
566                            cell.clear();
567                        }
568                        b"tr" => {
569                            if let Some(table) = table.as_mut()
570                                && !table.current_row.is_empty()
571                            {
572                                table.rows.push(std::mem::take(&mut table.current_row));
573                            }
574                        }
575                        b"table" => {
576                            if let Some(table) = table.take() {
577                                let data = finish_table(table);
578                                if !data.headers.is_empty() {
579                                    blocks.push(HtmlBlock::Table(data));
580                                }
581                            }
582                        }
583                        _ => {}
584                    }
585                }
586                depth = depth.saturating_sub(1);
587            }
588            Event::DocType(_) => {
589                return Err(Error::InvalidInput(
590                    "FictionBook document type declarations are not supported".into(),
591                ));
592            }
593            Event::Eof => break,
594            _ => {}
595        }
596        buffer.clear();
597    }
598    flush_paragraph(&mut blocks, &mut paragraph, &mut rendered_text_bytes)?;
599    Ok((blocks, warnings))
600}
601
602fn extract_metadata(xml: &str, max_events: usize) -> Result<Metadata> {
603    let mut reader = Reader::from_str(xml);
604    reader.config_mut().trim_text(true);
605    let mut buffer = Vec::new();
606    let mut metadata = Metadata::default();
607    let mut current = None::<&'static str>;
608    let mut events = 0usize;
609    loop {
610        events = events.saturating_add(1);
611        if events > max_events {
612            return Err(Error::LimitExceeded(format!(
613                "FictionBook metadata exceeds {max_events} parser events"
614            )));
615        }
616        match reader.read_event_into(&mut buffer)? {
617            Event::Start(element) => match local_name(element.name().as_ref()) {
618                b"book-title" => current = Some("title"),
619                b"first-name" | b"middle-name" | b"last-name" => current = Some("author"),
620                b"body" => break,
621                _ => {}
622            },
623            Event::Text(text) => {
624                let value = String::from_utf8_lossy(text.as_ref());
625                match current {
626                    Some("title") => metadata.title.push_str(&value),
627                    Some("author") => {
628                        if !metadata.author.is_empty() {
629                            metadata.author.push(' ');
630                        }
631                        metadata.author.push_str(&value);
632                    }
633                    _ => {}
634                }
635            }
636            Event::CData(text) => {
637                let value = String::from_utf8_lossy(text.as_ref());
638                match current {
639                    Some("title") => metadata.title.push_str(&value),
640                    Some("author") => {
641                        if !metadata.author.is_empty() {
642                            metadata.author.push(' ');
643                        }
644                        metadata.author.push_str(&value);
645                    }
646                    _ => {}
647                }
648            }
649            Event::End(element) => {
650                if matches!(
651                    local_name(element.name().as_ref()),
652                    b"book-title" | b"first-name" | b"middle-name" | b"last-name"
653                ) {
654                    current = None;
655                }
656            }
657            Event::Eof => break,
658            _ => {}
659        }
660        buffer.clear();
661    }
662    metadata.title = clean_fb2_text(&metadata.title);
663    metadata.author = clean_fb2_text(&metadata.author);
664    Ok(metadata)
665}
666
667#[allow(clippy::too_many_arguments)]
668fn append_image(
669    blocks: &mut Vec<HtmlBlock>,
670    warnings: &mut Vec<String>,
671    images: &HashMap<String, InlineHtmlImage>,
672    reference: &str,
673    paragraph: &mut Option<String>,
674    cell: &mut String,
675    in_cell: bool,
676    text_bytes: &mut usize,
677) -> Result<()> {
678    let key = reference.trim().trim_start_matches('#');
679    if let Some(image) = images.get(key) {
680        if in_cell {
681            cell.push_str("[Embedded image]");
682        } else {
683            flush_paragraph(blocks, paragraph, text_bytes)?;
684            blocks.push(HtmlBlock::Image {
685                href: image.href.clone(),
686                pixel_width: image.pixel_width,
687                pixel_height: image.pixel_height,
688                alt: "Embedded FictionBook image".into(),
689            });
690        }
691    } else {
692        push_warning_once(
693            warnings,
694            "FictionBook image reference did not resolve to an embedded PNG/JPEG",
695        );
696        if in_cell {
697            cell.push_str("[Image omitted]");
698        }
699    }
700    Ok(())
701}
702
703fn flush_paragraph(
704    blocks: &mut Vec<HtmlBlock>,
705    paragraph: &mut Option<String>,
706    text_bytes: &mut usize,
707) -> Result<()> {
708    let Some(text) = paragraph.take() else {
709        return Ok(());
710    };
711    let text = clean_fb2_text(&text);
712    if text.is_empty() {
713        return Ok(());
714    }
715    *text_bytes = text_bytes.saturating_add(text.len());
716    if *text_bytes > MAX_FB2_TEXT_BYTES {
717        return Err(Error::LimitExceeded(format!(
718            "FictionBook rendered text exceeds {MAX_FB2_TEXT_BYTES} bytes"
719        )));
720    }
721    blocks.push(HtmlBlock::Paragraph { text });
722    Ok(())
723}
724
725fn finish_table(table: TableBuilder) -> TableData {
726    let mut rows = table.rows;
727    let mut headers = rows.first().cloned().unwrap_or_default();
728    if !rows.is_empty() {
729        rows.remove(0);
730    }
731    let columns = headers
732        .len()
733        .max(rows.iter().map(Vec::len).max().unwrap_or(0))
734        .max(1);
735    headers.resize(columns, String::new());
736    for row in &mut rows {
737        row.resize(columns, String::new());
738    }
739    TableData {
740        headers,
741        rows,
742        alignments: vec![TableAlign::Left; columns],
743        raw_source: String::new(),
744    }
745}
746
747fn clean_fb2_text(value: &str) -> String {
748    value.split_whitespace().collect::<Vec<_>>().join(" ")
749}
750
751fn attribute(element: &quick_xml::events::BytesStart<'_>, name: &[u8]) -> Option<String> {
752    element
753        .attributes()
754        .with_checks(false)
755        .flatten()
756        .find_map(|attribute| {
757            let key = local_name(attribute.key.as_ref());
758            (key == name || attribute.key.as_ref() == name)
759                .then(|| String::from_utf8_lossy(attribute.value.as_ref()).into_owned())
760        })
761}
762
763fn push_warning_once(warnings: &mut Vec<String>, warning: &str) {
764    if !warnings.iter().any(|existing| existing == warning) {
765        warnings.push(warning.to_owned());
766    }
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn sniffs_fictionbook_root_and_metadata() {
775        let xml = r#"<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"><description><title-info><book-title>Book</book-title><author><first-name>A</first-name><last-name>B</last-name></author></title-info></description><body><section><title><p>Chapter</p></title><p>Text</p></section></body></FictionBook>"#;
776        assert!(looks_like_prefix(xml.as_bytes()));
777        let metadata = extract_metadata(xml, 1000).unwrap();
778        assert_eq!(metadata.title, "Book");
779        assert_eq!(metadata.author, "A B");
780    }
781}