1use 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}