1use std::collections::HashMap;
11use std::fs::File;
12use std::io::Read;
13use std::path::Path;
14
15use base64::Engine;
16use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
17use quick_xml::Reader;
18use quick_xml::events::{BytesStart, Event};
19
20use crate::convert::{ConvertOptions, PageConsumer};
21use crate::document::html::{
22 HtmlBlock, HtmlTextBlockKind, HtmlTextRun, HtmlTextStyle, render_blocks_to_pages,
23};
24use crate::error::{Error, Result};
25use crate::ooxml::{
26 ZipPackage, attribute, color_from_hex, decode_xml_reference, local_name, resolve_part_target,
27 sniff_image_mime,
28};
29use crate::table::{TableAlign, TableData};
30
31const ODT_MIMETYPE_LIMIT: u64 = 256;
32const MAX_ODT_REPEAT: usize = 10_000;
33const MAX_ODT_TABLE_CELLS: usize = 200_000;
34const MAX_ODT_XML_DEPTH: usize = 256;
35const MAX_ODT_STYLES: usize = 100_000;
36const MAX_ODT_STYLE_NAME_BYTES: usize = 1_024;
37const MAX_ODT_TEXT_BYTES: usize = 32 * 1024 * 1024;
38const MAX_ODT_TEXT_RUNS: usize = 200_000;
39const MAX_ODT_EXPANDED_TABLE_TEXT_BYTES: usize = 32 * 1024 * 1024;
40const MAX_ODT_IMAGES: usize = 10_000;
41const MAX_ODT_IMAGE_BYTES: u64 = 8 * 1024 * 1024;
42const MAX_ODT_TOTAL_IMAGE_BYTES: usize = 32 * 1024 * 1024;
43const MAX_ODT_TOTAL_DATA_URI_BYTES: usize = 48 * 1024 * 1024;
44const MAX_ODT_IMAGE_PIXELS: u64 = 40_000_000;
45const MAX_ODT_TOTAL_IMAGE_PIXELS: u64 = 100_000_000;
46
47#[derive(Clone, Debug)]
48enum ParagraphKind {
49 Heading(u8),
50 Paragraph,
51 ListItem,
52}
53
54#[derive(Clone, Debug)]
55struct Paragraph {
56 kind: ParagraphKind,
57 base_style: HtmlTextStyle,
58 current_style: HtmlTextStyle,
59 runs: Vec<HtmlTextRun>,
60}
61
62#[derive(Clone, Default)]
63struct OdtNamedStyle {
64 properties: HtmlTextStyle,
65 parent: Option<String>,
66}
67
68#[derive(Default)]
69struct OdtStyles {
70 paragraph: HashMap<String, OdtNamedStyle>,
71 text: HashMap<String, OdtNamedStyle>,
72 default_paragraph: HtmlTextStyle,
73 default_text: HtmlTextStyle,
74 font_faces: HashMap<String, String>,
75 count: usize,
76 unsupported_text_properties: bool,
77 unsupported_paragraph_properties: bool,
78}
79
80struct OdtStyleBuilder {
81 family: String,
82 name: Option<String>,
83 parent: Option<String>,
84 properties: HtmlTextStyle,
85 is_default: bool,
86 depth: usize,
87}
88
89impl OdtStyles {
90 fn resolve_paragraph(&self, name: Option<&str>) -> (HtmlTextStyle, bool) {
91 let mut style = self.default_paragraph.clone();
92 merge_text_style(&mut style, self.default_text.clone());
93 let missing = name.is_some_and(|name| {
94 resolve_odt_named_style(name, &self.paragraph, &self.font_faces, &mut style)
95 });
96 if let Some(font_name) = style.font_family.as_deref()
97 && let Some(font_family) = self.font_faces.get(font_name)
98 {
99 style.font_family = Some(font_family.clone());
100 }
101 (style, missing)
102 }
103
104 fn resolve_text(&self, name: Option<&str>) -> (HtmlTextStyle, bool) {
105 let mut style = HtmlTextStyle::default();
106 let missing = name.is_some_and(|name| {
107 resolve_odt_named_style(name, &self.text, &self.font_faces, &mut style)
108 });
109 if let Some(font_name) = style.font_family.as_deref()
110 && let Some(font_family) = self.font_faces.get(font_name)
111 {
112 style.font_family = Some(font_family.clone());
113 }
114 (style, missing)
115 }
116}
117
118fn merge_text_style(target: &mut HtmlTextStyle, source: HtmlTextStyle) {
119 if source.font_family.is_some() {
120 target.font_family = source.font_family;
121 }
122 if source.font_size.is_some() {
123 target.font_size = source.font_size;
124 }
125 if source.bold.is_some() {
126 target.bold = source.bold;
127 }
128 if source.italic.is_some() {
129 target.italic = source.italic;
130 }
131 if source.color.is_some() {
132 target.color = source.color;
133 }
134}
135
136fn resolve_odt_named_style(
137 name: &str,
138 styles: &HashMap<String, OdtNamedStyle>,
139 font_faces: &HashMap<String, String>,
140 target: &mut HtmlTextStyle,
141) -> bool {
142 let mut chain = Vec::new();
143 let mut seen = HashMap::new();
144 let mut current = Some(name.to_owned());
145 let mut missing = false;
146 while let Some(style_name) = current {
147 if chain.len() >= 32 || seen.insert(style_name.clone(), ()).is_some() {
148 missing = true;
149 break;
150 }
151 let Some(style) = styles.get(&style_name) else {
152 missing = true;
153 break;
154 };
155 chain.push(style.properties.clone());
156 current = style.parent.clone();
157 }
158 for properties in chain.into_iter().rev() {
159 merge_text_style(target, properties);
160 }
161 if let Some(font_name) = target.font_family.as_deref()
162 && let Some(font_family) = font_faces.get(font_name)
163 {
164 target.font_family = Some(font_family.clone());
165 }
166 missing
167}
168
169fn parse_style_definitions(xml: &str, max_events: usize, styles: &mut OdtStyles) -> Result<()> {
170 let mut reader = Reader::from_str(xml);
171 reader.config_mut().trim_text(false);
172 let mut buffer = Vec::new();
173 let mut stack = Vec::<String>::new();
174 let mut current = None::<OdtStyleBuilder>;
175 let mut events = 0usize;
176 loop {
177 events = events.saturating_add(1);
178 if events > max_events {
179 return Err(Error::LimitExceeded(format!(
180 "ODT style XML exceeds {max_events} parser events"
181 )));
182 }
183 match reader.read_event_into(&mut buffer)? {
184 Event::Start(element) => {
185 let name =
186 String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned();
187 if matches!(name.as_str(), "style" | "default-style") && current.is_none() {
188 if let Some(family) = attribute(&element, b"family")
189 && matches!(family.as_str(), "paragraph" | "text")
190 {
191 current = Some(OdtStyleBuilder {
192 family,
193 name: bounded_odt_style_name(&element, b"name")?,
194 parent: bounded_odt_style_name(&element, b"parent-style-name")?,
195 properties: HtmlTextStyle::default(),
196 is_default: name == "default-style",
197 depth: stack.len() + 1,
198 });
199 }
200 } else if let Some(builder) = current.as_mut() {
201 if name == "text-properties" {
202 merge_text_style(
203 &mut builder.properties,
204 parse_text_properties(&element, styles),
205 );
206 } else if name == "paragraph-properties" && has_xml_attributes(&element) {
207 styles.unsupported_paragraph_properties = true;
208 }
209 }
210 if name == "font-face"
211 && let (Some(name), Some(family)) = (
212 attribute(&element, b"name"),
213 attribute(&element, b"font-family"),
214 )
215 && name.len() <= 256
216 && family.len() <= 256
217 && !family.chars().any(char::is_control)
218 {
219 if styles.font_faces.len() >= MAX_ODT_STYLES
220 && !styles.font_faces.contains_key(&name)
221 {
222 return Err(Error::LimitExceeded(format!(
223 "ODT font face count exceeds {MAX_ODT_STYLES}"
224 )));
225 }
226 styles.font_faces.insert(name, unquote_font_family(&family));
227 }
228 if stack.len() >= MAX_ODT_XML_DEPTH {
229 return Err(Error::LimitExceeded(format!(
230 "ODT style XML nesting exceeds {MAX_ODT_XML_DEPTH} elements"
231 )));
232 }
233 stack.push(name);
234 }
235 Event::Empty(element) => {
236 let name =
237 String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned();
238 if matches!(name.as_str(), "style" | "default-style") && current.is_none() {
239 if let Some(family) = attribute(&element, b"family")
240 && matches!(family.as_str(), "paragraph" | "text")
241 {
242 finish_odt_style(
243 OdtStyleBuilder {
244 family,
245 name: bounded_odt_style_name(&element, b"name")?,
246 parent: bounded_odt_style_name(&element, b"parent-style-name")?,
247 properties: HtmlTextStyle::default(),
248 is_default: name == "default-style",
249 depth: stack.len(),
250 },
251 styles,
252 )?;
253 }
254 } else if let Some(builder) = current.as_mut() {
255 if name == "text-properties" {
256 merge_text_style(
257 &mut builder.properties,
258 parse_text_properties(&element, styles),
259 );
260 } else if name == "paragraph-properties" && has_xml_attributes(&element) {
261 styles.unsupported_paragraph_properties = true;
262 }
263 }
264 if name == "font-face"
265 && let (Some(name), Some(family)) = (
266 attribute(&element, b"name"),
267 attribute(&element, b"font-family"),
268 )
269 && name.len() <= 256
270 && family.len() <= 256
271 && !family.chars().any(char::is_control)
272 {
273 if styles.font_faces.len() >= MAX_ODT_STYLES
274 && !styles.font_faces.contains_key(&name)
275 {
276 return Err(Error::LimitExceeded(format!(
277 "ODT font face count exceeds {MAX_ODT_STYLES}"
278 )));
279 }
280 styles.font_faces.insert(name, unquote_font_family(&family));
281 }
282 }
283 Event::End(element) => {
284 let name =
285 String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned();
286 if matches!(name.as_str(), "style" | "default-style")
287 && current
288 .as_ref()
289 .is_some_and(|builder| builder.depth == stack.len())
290 && let Some(builder) = current.take()
291 {
292 finish_odt_style(builder, styles)?;
293 }
294 if stack.pop().as_deref() != Some(name.as_str()) {
295 return Err(Error::InvalidInput(format!(
296 "mismatched ODT style XML end tag '{name}'"
297 )));
298 }
299 }
300 Event::DocType(_) => {
301 return Err(Error::InvalidInput(
302 "ODT style XML must not contain a document type declaration".into(),
303 ));
304 }
305 Event::Eof => break,
306 _ => {}
307 }
308 buffer.clear();
309 }
310 if current.is_some() || !stack.is_empty() {
311 return Err(Error::InvalidInput(
312 "ODT style XML ended with an incomplete element".into(),
313 ));
314 }
315 Ok(())
316}
317
318fn finish_odt_style(builder: OdtStyleBuilder, styles: &mut OdtStyles) -> Result<()> {
319 if builder.is_default {
320 let target = if builder.family == "paragraph" {
321 &mut styles.default_paragraph
322 } else {
323 &mut styles.default_text
324 };
325 merge_text_style(target, builder.properties);
326 return Ok(());
327 }
328 let Some(name) = builder.name else {
329 return Ok(());
330 };
331 let map = if builder.family == "paragraph" {
332 &mut styles.paragraph
333 } else {
334 &mut styles.text
335 };
336 if !map.contains_key(&name) {
337 styles.count = styles.count.saturating_add(1);
338 if styles.count > MAX_ODT_STYLES {
339 return Err(Error::LimitExceeded(format!(
340 "ODT style count exceeds {MAX_ODT_STYLES}"
341 )));
342 }
343 }
344 map.insert(
345 name,
346 OdtNamedStyle {
347 properties: builder.properties,
348 parent: builder.parent,
349 },
350 );
351 Ok(())
352}
353
354fn parse_text_properties(element: &BytesStart<'_>, styles: &mut OdtStyles) -> HtmlTextStyle {
355 let mut result = HtmlTextStyle::default();
356 let supported = [
357 "font-name",
358 "font-family",
359 "font-size",
360 "font-weight",
361 "font-style",
362 "color",
363 ];
364 for attribute_value in element.attributes().with_checks(false).flatten() {
365 let name = local_name(attribute_value.key.as_ref());
366 if !supported
367 .iter()
368 .any(|supported| name == supported.as_bytes())
369 {
370 styles.unsupported_text_properties = true;
371 }
372 }
373 let font_family =
374 attribute(element, b"font-name").or_else(|| attribute(element, b"font-family"));
375 if let Some(value) = font_family {
376 if value.len() <= 256 && !value.chars().any(char::is_control) {
377 let value = unquote_font_family(&value);
378 if !value.is_empty() {
379 result.font_family = Some(value);
380 }
381 } else {
382 styles.unsupported_text_properties = true;
383 }
384 }
385 if let Some(value) = attribute(element, b"font-size") {
386 result.font_size = parse_odt_font_size(&value).filter(|size| (1.0..=512.0).contains(size));
387 if result.font_size.is_none() {
388 styles.unsupported_text_properties = true;
389 }
390 }
391 if let Some(value) = attribute(element, b"font-weight") {
392 result.bold = match value.to_ascii_lowercase().as_str() {
393 "bold" | "bolder" => Some(true),
394 "normal" | "lighter" => Some(false),
395 _ => value.parse::<u16>().ok().map(|weight| weight >= 600),
396 };
397 if result.bold.is_none() {
398 styles.unsupported_text_properties = true;
399 }
400 }
401 if let Some(value) = attribute(element, b"font-style") {
402 result.italic = match value.to_ascii_lowercase().as_str() {
403 "normal" => Some(false),
404 "italic" | "oblique" => Some(true),
405 _ => None,
406 };
407 if result.italic.is_none() {
408 styles.unsupported_text_properties = true;
409 }
410 }
411 if let Some(value) = attribute(element, b"color") {
412 if is_odt_hex_color(&value) {
413 result.color = Some(color_from_hex(&value, "#334155"));
414 } else {
415 styles.unsupported_text_properties = true;
416 }
417 }
418 result
419}
420
421fn has_xml_attributes(element: &BytesStart<'_>) -> bool {
422 element.attributes().with_checks(false).next().is_some()
423}
424
425fn bounded_odt_style_name(element: &BytesStart<'_>, name: &[u8]) -> Result<Option<String>> {
426 let value = attribute(element, name);
427 if value
428 .as_ref()
429 .is_some_and(|value| value.len() > MAX_ODT_STYLE_NAME_BYTES)
430 {
431 return Err(Error::LimitExceeded(format!(
432 "ODT style identifier exceeds {MAX_ODT_STYLE_NAME_BYTES} bytes"
433 )));
434 }
435 Ok(value)
436}
437
438fn parse_odt_font_size(value: &str) -> Option<f64> {
439 let value = value.trim().to_ascii_lowercase();
440 for (unit, scale) in [
441 ("pt", 1.0),
442 ("pc", 12.0),
443 ("in", 72.0),
444 ("cm", 72.0 / 2.54),
445 ("mm", 72.0 / 25.4),
446 ("px", 0.75),
447 ] {
448 if let Some(number) = value.strip_suffix(unit) {
449 return number
450 .trim()
451 .parse::<f64>()
452 .ok()
453 .filter(|number| number.is_finite())
454 .map(|number| number * scale);
455 }
456 }
457 None
458}
459
460fn unquote_font_family(value: &str) -> String {
461 value.trim().trim_matches(['"', '\'']).trim().to_owned()
462}
463
464fn is_odt_hex_color(value: &str) -> bool {
465 value.starts_with('#')
466 && matches!(value.len(), 4 | 7)
467 && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
468}
469
470#[derive(Clone, Debug, Default)]
471struct TableBuilder {
472 headers: Vec<String>,
473 rows: Vec<Vec<String>>,
474 total_cells: usize,
475 total_text_bytes: usize,
476}
477
478#[derive(Default)]
479struct OdtImageBudget {
480 count: usize,
481 decoded_bytes: usize,
482 data_uri_bytes: usize,
483 pixels: u64,
484}
485
486struct PendingImage {
487 depth: usize,
488 href: Option<String>,
489 alt: String,
490 has_inline_binary: bool,
491 inline_data: Option<String>,
492 was_in_table: bool,
493 over_limit: bool,
494}
495
496struct OdtImageContext<'a> {
497 package: Option<&'a mut ZipPackage<File>>,
498 budget: &'a mut OdtImageBudget,
499 warnings: &'a mut Vec<String>,
500 blocks: &'a mut Vec<HtmlBlock>,
501 cell: &'a mut Option<String>,
502 warned_table_images: &'a mut bool,
503 text_bytes: &'a mut usize,
504 text_run_count: &'a mut usize,
505}
506
507pub(crate) fn convert(
508 path: &Path,
509 options: &ConvertOptions,
510 sink: &mut dyn PageConsumer,
511) -> Result<Vec<String>> {
512 let (bytes, styles_xml, mut package) = if path
513 .extension()
514 .and_then(|extension| extension.to_str())
515 .is_some_and(|extension| extension.eq_ignore_ascii_case("fodt"))
516 {
517 let mut file = File::open(path)?;
518 let mut bytes = Vec::new();
519 Read::take(&mut file, options.max_input_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
520 if bytes.len() as u64 > options.max_input_bytes {
521 return Err(Error::LimitExceeded(format!(
522 "FODT input exceeds maximum bytes ({})",
523 options.max_input_bytes
524 )));
525 }
526 (bytes, None, None)
527 } else {
528 let mut package = ZipPackage::open(path, options.max_zip_entry_bytes)?;
529 let mimetype = package.read_limited("mimetype", ODT_MIMETYPE_LIMIT)?;
530 let mimetype = std::str::from_utf8(&mimetype)
531 .map_err(|error| Error::InvalidInput(format!("ODT mimetype is not UTF-8: {error}")))?
532 .trim();
533 if !matches!(
534 mimetype,
535 "application/vnd.oasis.opendocument.text"
536 | "application/vnd.oasis.opendocument.text-template"
537 | "application/vnd.sun.xml.writer"
538 | "application/vnd.sun.xml.writer.template"
539 ) {
540 return Err(Error::InvalidInput(format!(
541 "unsupported OpenDocument text mimetype '{mimetype}'"
542 )));
543 }
544 let content = package.read("content.xml")?;
545 let styles = package.read_optional("styles.xml")?;
546 (content, styles, Some(package))
547 };
548
549 let xml = String::from_utf8(bytes)
550 .map_err(|error| Error::InvalidInput(format!("ODT content is not UTF-8: {error}")))?;
551 let mut styles = OdtStyles::default();
552 if let Some(styles_xml) = styles_xml {
553 let styles_xml = String::from_utf8(styles_xml)
554 .map_err(|error| Error::InvalidInput(format!("ODT styles are not UTF-8: {error}")))?;
555 parse_style_definitions(&styles_xml, options.max_xml_events, &mut styles)?;
556 }
557 parse_style_definitions(&xml, options.max_xml_events, &mut styles)?;
558 let (blocks, mut warnings) =
559 parse_content_with_styles(&xml, options.max_xml_events, package.as_mut(), &styles)?;
560 if blocks.is_empty() {
561 return Err(Error::InvalidInput(
562 "OpenDocument Text contains no renderable text or tables".into(),
563 ));
564 }
565 warnings.insert(
566 0,
567 "ODT bold, italic, text color, font size, and font family text styles are applied where supported; paragraph spacing/alignment, table styles, exact page geometry, and pagination are approximated".into(),
568 );
569 render_blocks_to_pages(&blocks, sink, options)?;
570 Ok(warnings)
571}
572
573#[cfg(test)]
574fn parse_content(
575 xml: &str,
576 max_events: usize,
577 package: Option<&mut ZipPackage<File>>,
578) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
579 parse_content_with_styles(xml, max_events, package, &OdtStyles::default())
580}
581
582fn parse_content_with_styles(
583 xml: &str,
584 max_events: usize,
585 mut package: Option<&mut ZipPackage<File>>,
586 styles: &OdtStyles,
587) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
588 let mut reader = Reader::from_str(xml);
589 reader.config_mut().trim_text(false);
590 let mut buffer = Vec::new();
591 let mut stack = Vec::new();
592 let mut blocks = Vec::new();
593 let mut warnings = Vec::new();
594 if styles.unsupported_text_properties {
595 warnings.push("some ODT character style properties are not rendered".into());
596 }
597 if styles.unsupported_paragraph_properties {
598 warnings.push("ODT paragraph spacing/alignment style properties are approximated".into());
599 }
600 let mut in_text_body = false;
601 let mut list_depth = 0usize;
602 let mut header_rows_depth = 0usize;
603 let mut paragraph: Option<Paragraph> = None;
604 let mut text_style_stack: Vec<HtmlTextStyle> = Vec::new();
605 let mut table: Option<TableBuilder> = None;
606 let mut row: Option<Vec<String>> = None;
607 let mut row_text_bytes = 0usize;
608 let mut row_is_header = false;
609 let mut row_repeats = 1usize;
610 let mut cell: Option<String> = None;
611 let mut cell_repeats = 1usize;
612 let mut event_count = 0usize;
613 let mut image_budget = OdtImageBudget::default();
614 let mut pending_image: Option<PendingImage> = None;
615 let mut frame_alt: Vec<String> = Vec::new();
616 let mut warned_image_flow = false;
617 let mut warned_table_images = false;
618 let mut warned_spans = false;
619 let mut warned_table_styles = false;
620 let mut warned_missing_style = false;
621 let mut text_bytes = 0usize;
622 let mut text_run_count = 0usize;
623
624 loop {
625 event_count = event_count.saturating_add(1);
626 if event_count > max_events {
627 return Err(Error::LimitExceeded(format!(
628 "ODT content.xml exceeds {max_events} parser events"
629 )));
630 }
631
632 match reader.read_event_into(&mut buffer)? {
633 Event::Start(ref element) => {
634 let name =
635 String::from_utf8_lossy(local_name(element.name().as_ref())).into_owned();
636 if name == "text" && stack.last().is_some_and(|parent| parent == "body") {
637 in_text_body = true;
638 }
639 if in_text_body {
640 match name.as_str() {
641 "frame" => frame_alt.push(attribute(element, b"name").unwrap_or_default()),
642 "list" => list_depth = list_depth.saturating_add(1),
643 "list-item" => {}
644 "table-header-rows" => {
645 header_rows_depth = header_rows_depth.saturating_add(1)
646 }
647 "table" => {
648 if table.is_some() {
649 return Err(Error::Unsupported(
650 "nested OpenDocument tables are not supported".into(),
651 ));
652 }
653 flush_paragraph(
654 &mut paragraph,
655 &mut cell,
656 &mut blocks,
657 &mut warnings,
658 &mut warned_table_styles,
659 );
660 table = Some(TableBuilder::default());
661 }
662 "table-row" => {
663 if row.is_some() {
664 return Err(Error::InvalidInput(
665 "nested OpenDocument table rows are invalid".into(),
666 ));
667 }
668 row = Some(Vec::new());
669 row_text_bytes = 0;
670 row_is_header = header_rows_depth > 0;
671 row_repeats = parse_repeat(element, b"number-rows-repeated")?;
672 }
673 "table-cell" => {
674 if cell.is_some() {
675 return Err(Error::InvalidInput(
676 "nested OpenDocument table cells are invalid".into(),
677 ));
678 }
679 let has_spanned_cells = [
680 attribute(element, b"number-columns-spanned"),
681 attribute(element, b"number-rows-spanned"),
682 ]
683 .into_iter()
684 .flatten()
685 .filter_map(|value| value.parse::<usize>().ok())
686 .any(|span| span > 1);
687 if has_spanned_cells && !warned_spans {
688 warnings.push(
689 "ODT merged table cells are not expanded in the preview".into(),
690 );
691 warned_spans = true;
692 }
693 cell = Some(String::new());
694 cell_repeats = parse_repeat(element, b"number-columns-repeated")?;
695 }
696 "covered-table-cell" => {
697 cell = Some(String::new());
698 cell_repeats = 1;
699 }
700 "p" => {
701 let kind = if list_depth > 0 {
702 ParagraphKind::ListItem
703 } else {
704 ParagraphKind::Paragraph
705 };
706 let style_name = bounded_odt_style_name(element, b"style-name")?;
707 let (style, missing) = styles.resolve_paragraph(style_name.as_deref());
708 if missing && !warned_missing_style {
709 warnings.push("some ODT paragraph styles were missing or had cyclic/deep inheritance; default text properties were used".into());
710 warned_missing_style = true;
711 }
712 paragraph = Some(new_paragraph(kind, style));
713 text_style_stack.clear();
714 }
715 "h" => {
716 let level = attribute(element, b"outline-level")
717 .and_then(|value| value.parse::<u8>().ok())
718 .unwrap_or(1)
719 .clamp(1, 6);
720 let style_name = bounded_odt_style_name(element, b"style-name")?;
721 let (style, missing) = styles.resolve_paragraph(style_name.as_deref());
722 if missing && !warned_missing_style {
723 warnings.push("some ODT paragraph styles were missing or had cyclic/deep inheritance; default text properties were used".into());
724 warned_missing_style = true;
725 }
726 paragraph = Some(new_paragraph(ParagraphKind::Heading(level), style));
727 text_style_stack.clear();
728 }
729 "span" if paragraph.is_some() => {
730 let style_name = bounded_odt_style_name(element, b"style-name")?;
731 let (span_style, missing) = styles.resolve_text(style_name.as_deref());
732 if missing && !warned_missing_style {
733 warnings.push("some ODT character styles were missing or had cyclic/deep inheritance; inherited text properties were used".into());
734 warned_missing_style = true;
735 }
736 if let Some(paragraph) = paragraph.as_mut() {
737 let mut merged = paragraph.current_style.clone();
738 merge_text_style(&mut merged, span_style);
739 paragraph.current_style = merged.clone();
740 text_style_stack.push(merged);
741 }
742 }
743 "image" => {
744 if pending_image.is_some() {
745 return Err(Error::InvalidInput(
746 "nested OpenDocument images are invalid".into(),
747 ));
748 }
749 let (kind, base_style, current_style) = paragraph
750 .as_ref()
751 .map(|paragraph| {
752 (
753 paragraph.kind.clone(),
754 paragraph.base_style.clone(),
755 paragraph.current_style.clone(),
756 )
757 })
758 .unwrap_or_else(|| {
759 let (style, missing) = styles.resolve_paragraph(None);
760 if missing && !warned_missing_style {
761 warnings.push("some ODT paragraph styles were missing or had cyclic/deep inheritance; default text properties were used".into());
762 warned_missing_style = true;
763 }
764 (
765 if list_depth > 0 {
766 ParagraphKind::ListItem
767 } else {
768 ParagraphKind::Paragraph
769 },
770 style.clone(),
771 style,
772 )
773 });
774 flush_paragraph(
775 &mut paragraph,
776 &mut cell,
777 &mut blocks,
778 &mut warnings,
779 &mut warned_table_styles,
780 );
781 let over_limit = !reserve_odt_image(&mut image_budget, &mut warnings);
782 pending_image = Some(PendingImage {
783 depth: stack.len() + 1,
784 href: attribute(element, b"href"),
785 alt: attribute(element, b"name")
786 .filter(|value| !value.trim().is_empty())
787 .or_else(|| {
788 frame_alt
789 .last()
790 .filter(|value| !value.trim().is_empty())
791 .cloned()
792 })
793 .unwrap_or_else(|| "Embedded image".into()),
794 has_inline_binary: false,
795 inline_data: None,
796 was_in_table: table.is_some(),
797 over_limit,
798 });
799 if !warned_image_flow && cell.is_none() {
800 warnings.push("ODT inline image anchors, wrapping, and frame placement are approximated as centered flow blocks".into());
801 warned_image_flow = true;
802 }
803 if cell.is_none() {
804 paragraph = Some(Paragraph {
806 kind,
807 base_style,
808 current_style,
809 runs: Vec::new(),
810 });
811 }
812 }
813 "binary-data" if pending_image.is_some() => {
814 if let Some(image) = pending_image.as_mut() {
815 image.has_inline_binary = true;
816 image.inline_data = Some(String::new());
817 }
818 }
819 _ => {}
820 }
821 }
822 if stack.len() >= MAX_ODT_XML_DEPTH {
823 return Err(Error::LimitExceeded(format!(
824 "ODT XML nesting exceeds {MAX_ODT_XML_DEPTH} elements"
825 )));
826 }
827 stack.push(name);
828 }
829 Event::Empty(ref element) => {
830 let qualified_name = element.name();
831 let name = local_name(qualified_name.as_ref());
832 if in_text_body {
833 match name {
834 b"s" => append_spaces(
835 &mut paragraph,
836 &mut cell,
837 element,
838 &mut text_bytes,
839 &mut text_run_count,
840 )?,
841 b"tab" => append_text(
842 &mut paragraph,
843 &mut cell,
844 "\t",
845 &mut text_bytes,
846 &mut text_run_count,
847 )?,
848 b"line-break" => append_text(
849 &mut paragraph,
850 &mut cell,
851 "\n",
852 &mut text_bytes,
853 &mut text_run_count,
854 )?,
855 b"covered-table-cell" => {
856 append_cell("".into(), 1, &mut row, &mut row_text_bytes)?
857 }
858 b"table-cell" => {
859 let repeats = parse_repeat(element, b"number-columns-repeated")?;
860 append_cell(String::new(), repeats, &mut row, &mut row_text_bytes)?;
861 }
862 b"image" => {
863 let (kind, base_style, current_style) = paragraph
864 .as_ref()
865 .map(|paragraph| {
866 (
867 paragraph.kind.clone(),
868 paragraph.base_style.clone(),
869 paragraph.current_style.clone(),
870 )
871 })
872 .unwrap_or_else(|| {
873 let (style, _) = styles.resolve_paragraph(None);
874 (
875 if list_depth > 0 {
876 ParagraphKind::ListItem
877 } else {
878 ParagraphKind::Paragraph
879 },
880 style.clone(),
881 style,
882 )
883 });
884 let alt = attribute(element, b"name")
885 .filter(|value| !value.trim().is_empty())
886 .or_else(|| {
887 frame_alt
888 .last()
889 .filter(|value| !value.trim().is_empty())
890 .cloned()
891 })
892 .unwrap_or_else(|| "Embedded image".into());
893 let was_in_table = table.is_some();
894 flush_paragraph(
895 &mut paragraph,
896 &mut cell,
897 &mut blocks,
898 &mut warnings,
899 &mut warned_table_styles,
900 );
901 if !warned_image_flow && cell.is_none() {
902 warnings.push("ODT inline image anchors, wrapping, and frame placement are approximated as centered flow blocks".into());
903 warned_image_flow = true;
904 }
905 if reserve_odt_image(&mut image_budget, &mut warnings) {
906 attach_odt_image(
907 attribute(element, b"href").as_deref(),
908 None,
909 &alt,
910 was_in_table,
911 OdtImageContext {
912 package: package.as_deref_mut(),
913 budget: &mut image_budget,
914 warnings: &mut warnings,
915 blocks: &mut blocks,
916 cell: &mut cell,
917 warned_table_images: &mut warned_table_images,
918 text_bytes: &mut text_bytes,
919 text_run_count: &mut text_run_count,
920 },
921 )?;
922 }
923 if cell.is_none() {
924 paragraph = Some(Paragraph {
925 kind,
926 base_style,
927 current_style,
928 runs: Vec::new(),
929 });
930 }
931 }
932 b"binary-data" if pending_image.is_some() => {
933 if let Some(image) = pending_image.as_mut() {
934 image.has_inline_binary = true;
935 image.inline_data = Some(String::new());
936 }
937 }
938 _ => {}
939 }
940 }
941 }
942 Event::Text(ref value) => {
943 if in_text_body && stack.iter().any(|element| element == "binary-data") {
944 let decoded = value.decode().map_err(|error| {
945 Error::InvalidInput(format!("invalid ODT inline image encoding: {error}"))
946 })?;
947 if let Some(image) = pending_image.as_mut()
948 && let Some(data) = image.inline_data.as_mut()
949 {
950 if data.len().saturating_add(decoded.len())
951 > (MAX_ODT_IMAGE_BYTES as usize).saturating_mul(2)
952 {
953 return Err(Error::LimitExceeded(format!(
954 "ODT inline image base64 exceeds the {}-byte limit",
955 MAX_ODT_IMAGE_BYTES.saturating_mul(2)
956 )));
957 }
958 data.push_str(&decoded);
959 }
960 } else if in_text_body {
961 let decoded = value.decode().map_err(|error| {
962 Error::InvalidInput(format!("invalid ODT text encoding: {error}"))
963 })?;
964 let text = quick_xml::escape::unescape(&decoded).map_err(|error| {
965 Error::InvalidInput(format!("invalid ODT XML text: {error}"))
966 })?;
967 append_text(
968 &mut paragraph,
969 &mut cell,
970 &text,
971 &mut text_bytes,
972 &mut text_run_count,
973 )?;
974 }
975 }
976 Event::GeneralRef(ref reference) => {
977 if in_text_body && !stack.iter().any(|element| element == "binary-data") {
978 let text = decode_xml_reference(reference, "ODT text")?;
979 append_text(
980 &mut paragraph,
981 &mut cell,
982 &text,
983 &mut text_bytes,
984 &mut text_run_count,
985 )?;
986 }
987 }
988 Event::CData(ref value) => {
989 if in_text_body && stack.iter().any(|element| element == "binary-data") {
990 let decoded = value.decode().map_err(|error| {
991 Error::InvalidInput(format!("invalid ODT inline image encoding: {error}"))
992 })?;
993 if let Some(image) = pending_image.as_mut()
994 && let Some(data) = image.inline_data.as_mut()
995 {
996 if data.len().saturating_add(decoded.len())
997 > (MAX_ODT_IMAGE_BYTES as usize).saturating_mul(2)
998 {
999 return Err(Error::LimitExceeded(format!(
1000 "ODT inline image base64 exceeds the {}-byte limit",
1001 MAX_ODT_IMAGE_BYTES.saturating_mul(2)
1002 )));
1003 }
1004 data.push_str(&decoded);
1005 }
1006 } else if in_text_body {
1007 let decoded = value.decode().map_err(|error| {
1008 Error::InvalidInput(format!("invalid ODT CDATA encoding: {error}"))
1009 })?;
1010 append_text(
1011 &mut paragraph,
1012 &mut cell,
1013 &decoded,
1014 &mut text_bytes,
1015 &mut text_run_count,
1016 )?;
1017 }
1018 }
1019 Event::DocType(_) => {
1020 return Err(Error::InvalidInput(
1021 "OpenDocument content must not contain a document type declaration".into(),
1022 ));
1023 }
1024 Event::End(ref element) => {
1025 let qualified_name = element.name();
1026 let name = local_name(qualified_name.as_ref());
1027 match name {
1028 b"image" => {
1029 let pending = pending_image.take().ok_or_else(|| {
1030 Error::InvalidInput("OpenDocument image ended without opening".into())
1031 })?;
1032 if pending.depth != stack.len() {
1033 return Err(Error::InvalidInput(
1034 "mismatched OpenDocument image nesting".into(),
1035 ));
1036 }
1037 if pending.over_limit {
1038 } else if pending.has_inline_binary {
1040 attach_odt_image(
1041 pending.href.as_deref(),
1042 pending.inline_data.as_deref(),
1043 &pending.alt,
1044 pending.was_in_table,
1045 OdtImageContext {
1046 package: package.as_deref_mut(),
1047 budget: &mut image_budget,
1048 warnings: &mut warnings,
1049 blocks: &mut blocks,
1050 cell: &mut cell,
1051 warned_table_images: &mut warned_table_images,
1052 text_bytes: &mut text_bytes,
1053 text_run_count: &mut text_run_count,
1054 },
1055 )?;
1056 } else {
1057 attach_odt_image(
1058 pending.href.as_deref(),
1059 None,
1060 &pending.alt,
1061 pending.was_in_table,
1062 OdtImageContext {
1063 package: package.as_deref_mut(),
1064 budget: &mut image_budget,
1065 warnings: &mut warnings,
1066 blocks: &mut blocks,
1067 cell: &mut cell,
1068 warned_table_images: &mut warned_table_images,
1069 text_bytes: &mut text_bytes,
1070 text_run_count: &mut text_run_count,
1071 },
1072 )?;
1073 }
1074 }
1075 b"frame" => {
1076 frame_alt.pop();
1077 }
1078 b"span" => {
1079 if let Some(paragraph) = paragraph.as_mut() {
1080 text_style_stack.pop();
1081 paragraph.current_style = text_style_stack
1082 .last()
1083 .cloned()
1084 .unwrap_or_else(|| paragraph.base_style.clone());
1085 }
1086 }
1087 b"p" | b"h" => {
1088 flush_paragraph(
1089 &mut paragraph,
1090 &mut cell,
1091 &mut blocks,
1092 &mut warnings,
1093 &mut warned_table_styles,
1094 );
1095 text_style_stack.clear();
1096 }
1097 b"table-cell" => {
1098 let value = cell.take().ok_or_else(|| {
1099 Error::InvalidInput("ODT table cell ended without opening".into())
1100 })?;
1101 append_cell(value, cell_repeats, &mut row, &mut row_text_bytes)?;
1102 cell_repeats = 1;
1103 }
1104 b"covered-table-cell" => {
1105 let value = cell.take().ok_or_else(|| {
1106 Error::InvalidInput("ODT covered cell ended without opening".into())
1107 })?;
1108 append_cell(value, cell_repeats, &mut row, &mut row_text_bytes)?;
1109 cell_repeats = 1;
1110 }
1111 b"table-row" => {
1112 let row_values = row.take().ok_or_else(|| {
1113 Error::InvalidInput("ODT table row ended without opening".into())
1114 })?;
1115 append_row(
1116 row_values,
1117 row_text_bytes,
1118 row_repeats,
1119 row_is_header,
1120 &mut table,
1121 )?;
1122 row_repeats = 1;
1123 row_is_header = false;
1124 }
1125 b"table-header-rows" => {
1126 header_rows_depth = header_rows_depth.saturating_sub(1);
1127 }
1128 b"table" => {
1129 let builder = table.take().ok_or_else(|| {
1130 Error::InvalidInput("ODT table ended without opening".into())
1131 })?;
1132 if !builder.headers.is_empty() || !builder.rows.is_empty() {
1133 let mut headers = builder.headers;
1134 let mut rows = builder.rows;
1135 if headers.is_empty() && !rows.is_empty() {
1136 headers = rows.remove(0);
1137 }
1138 let columns = headers
1139 .len()
1140 .max(rows.iter().map(Vec::len).max().unwrap_or(0));
1141 blocks.push(HtmlBlock::Table(TableData {
1142 headers,
1143 rows,
1144 alignments: vec![TableAlign::Left; columns],
1145 raw_source: String::new(),
1146 }));
1147 }
1148 }
1149 b"list" => list_depth = list_depth.saturating_sub(1),
1150 b"text" if stack.len() > 1 => in_text_body = false,
1151 _ => {}
1152 }
1153 let closing = String::from_utf8_lossy(name).into_owned();
1154 if stack.pop().as_deref() != Some(closing.as_str()) {
1155 return Err(Error::InvalidInput(format!(
1156 "mismatched OpenDocument XML end tag '{closing}'"
1157 )));
1158 }
1159 }
1160 Event::Eof => break,
1161 _ => {}
1162 }
1163 buffer.clear();
1164 }
1165
1166 if paragraph.is_some() || cell.is_some() || row.is_some() || table.is_some() {
1167 return Err(Error::InvalidInput(
1168 "incomplete OpenDocument content XML structure".into(),
1169 ));
1170 }
1171 if pending_image.is_some() {
1172 return Err(Error::InvalidInput(
1173 "incomplete OpenDocument image element".into(),
1174 ));
1175 }
1176 Ok((blocks, warnings))
1177}
1178
1179fn attach_odt_image(
1180 href: Option<&str>,
1181 inline_data: Option<&str>,
1182 alt: &str,
1183 was_in_table: bool,
1184 context: OdtImageContext<'_>,
1185) -> Result<()> {
1186 let OdtImageContext {
1187 package,
1188 budget,
1189 warnings,
1190 blocks,
1191 cell,
1192 warned_table_images,
1193 text_bytes,
1194 text_run_count,
1195 } = context;
1196 if was_in_table {
1197 if !*warned_table_images {
1198 push_odt_warning_once(
1199 warnings,
1200 "ODT images inside tables are represented by alt text because table-cell image layout is unavailable",
1201 );
1202 *warned_table_images = true;
1203 }
1204 append_text(
1205 &mut None,
1206 cell,
1207 &format!("[{}]", alt),
1208 text_bytes,
1209 text_run_count,
1210 )?;
1211 return Ok(());
1212 }
1213 if let Some(inline_data) = inline_data {
1214 return attach_odt_inline_image(inline_data, alt, warnings, budget, blocks);
1215 }
1216 let Some(package) = package else {
1217 push_odt_warning_once(
1218 warnings,
1219 "flat OpenDocument images and external resources are omitted; package-linked PNG/JPEG images are supported",
1220 );
1221 return Ok(());
1222 };
1223 let Some(href) = href.filter(|href| !href.trim().is_empty()) else {
1224 push_odt_warning_once(warnings, "ODT image without a package href was omitted");
1225 return Ok(());
1226 };
1227 if href.contains(':') || href.starts_with("//") || href.starts_with('\\') {
1228 push_odt_warning_once(warnings, "external ODT image resources are not fetched");
1229 return Ok(());
1230 }
1231 let package_href = href.strip_prefix('/').unwrap_or(href);
1234 let target = match resolve_part_target("content.xml", package_href) {
1235 Ok(target) => target,
1236 Err(_) => {
1237 push_odt_warning_once(
1238 warnings,
1239 "ODT image path escaped the package or was invalid and was omitted",
1240 );
1241 return Ok(());
1242 }
1243 };
1244 let bytes = match package.read_optional_limited(&target, MAX_ODT_IMAGE_BYTES) {
1245 Ok(Some(bytes)) => bytes,
1246 Ok(None) => {
1247 push_odt_warning_once(warnings, "missing ODT image parts were omitted");
1248 return Ok(());
1249 }
1250 Err(Error::LimitExceeded(_)) => {
1251 push_odt_warning_once(
1252 warnings,
1253 "ODT image parts exceeding the per-image byte limit were omitted",
1254 );
1255 return Ok(());
1256 }
1257 Err(error) => return Err(error),
1258 };
1259 let Some(mime) =
1260 sniff_image_mime(&bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
1261 else {
1262 push_odt_warning_once(
1263 warnings,
1264 "unsupported ODT image types were omitted; only PNG and JPEG are embedded",
1265 );
1266 return Ok(());
1267 };
1268 let Some((width, height)) = crate::document::mhtml::image_dimensions(&bytes, mime) else {
1269 push_odt_warning_once(warnings, "invalid ODT PNG/JPEG images were omitted");
1270 return Ok(());
1271 };
1272 let pixels = u64::from(width) * u64::from(height);
1273 let next_pixels = budget.pixels.saturating_add(pixels);
1274 if width == 0
1275 || height == 0
1276 || pixels > MAX_ODT_IMAGE_PIXELS
1277 || next_pixels > MAX_ODT_TOTAL_IMAGE_PIXELS
1278 {
1279 push_odt_warning_once(
1280 warnings,
1281 "ODT images exceeding the per-image or total pixel limit were omitted",
1282 );
1283 return Ok(());
1284 }
1285 let next_bytes = budget.decoded_bytes.saturating_add(bytes.len());
1286 if next_bytes > MAX_ODT_TOTAL_IMAGE_BYTES {
1287 push_odt_warning_once(
1288 warnings,
1289 "ODT images exceeding the total decoded image byte limit were omitted",
1290 );
1291 return Ok(());
1292 }
1293 let prefix = format!("data:{mime};base64,");
1294 let uri_bytes = prefix
1295 .len()
1296 .saturating_add(bytes.len().div_ceil(3).saturating_mul(4));
1297 let next_uri_bytes = budget.data_uri_bytes.saturating_add(uri_bytes);
1298 if next_uri_bytes > MAX_ODT_TOTAL_DATA_URI_BYTES {
1299 push_odt_warning_once(
1300 warnings,
1301 "ODT images exceeding the total data URI byte limit were omitted",
1302 );
1303 return Ok(());
1304 }
1305 let href = format!("{prefix}{}", BASE64_STANDARD.encode(&bytes));
1306 blocks.push(HtmlBlock::Image {
1307 href,
1308 pixel_width: width,
1309 pixel_height: height,
1310 alt: alt.to_owned(),
1311 });
1312 budget.decoded_bytes = next_bytes;
1313 budget.data_uri_bytes = next_uri_bytes;
1314 budget.pixels = next_pixels;
1315 Ok(())
1316}
1317
1318fn attach_odt_inline_image(
1319 data: &str,
1320 alt: &str,
1321 warnings: &mut Vec<String>,
1322 budget: &mut OdtImageBudget,
1323 blocks: &mut Vec<HtmlBlock>,
1324) -> Result<()> {
1325 let compact: String = data
1326 .chars()
1327 .filter(|character| !character.is_ascii_whitespace())
1328 .collect();
1329 let bytes = match BASE64_STANDARD.decode(compact.as_bytes()) {
1330 Ok(bytes) => bytes,
1331 Err(_) => {
1332 push_odt_warning_once(
1333 warnings,
1334 "malformed ODT inline office:binary-data image was omitted",
1335 );
1336 return Ok(());
1337 }
1338 };
1339 if bytes.len() as u64 > MAX_ODT_IMAGE_BYTES {
1340 push_odt_warning_once(
1341 warnings,
1342 "ODT inline image exceeded the per-image byte limit",
1343 );
1344 return Ok(());
1345 }
1346 let Some(mime) =
1347 sniff_image_mime(&bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
1348 else {
1349 push_odt_warning_once(
1350 warnings,
1351 "unsupported ODT inline image type was omitted; only PNG and JPEG are embedded",
1352 );
1353 return Ok(());
1354 };
1355 let Some((width, height)) = crate::document::mhtml::image_dimensions(&bytes, mime) else {
1356 push_odt_warning_once(warnings, "invalid ODT inline PNG/JPEG image was omitted");
1357 return Ok(());
1358 };
1359 let pixels = u64::from(width).saturating_mul(u64::from(height));
1360 let next_pixels = budget.pixels.saturating_add(pixels);
1361 if width == 0
1362 || height == 0
1363 || pixels > MAX_ODT_IMAGE_PIXELS
1364 || next_pixels > MAX_ODT_TOTAL_IMAGE_PIXELS
1365 {
1366 push_odt_warning_once(warnings, "ODT inline images exceeded the pixel limit");
1367 return Ok(());
1368 }
1369 let next_bytes = budget.decoded_bytes.saturating_add(bytes.len());
1370 if next_bytes > MAX_ODT_TOTAL_IMAGE_BYTES {
1371 push_odt_warning_once(warnings, "ODT inline images exceeded the total byte limit");
1372 return Ok(());
1373 }
1374 let prefix = format!("data:{mime};base64,");
1375 let uri_bytes = prefix
1376 .len()
1377 .saturating_add(bytes.len().div_ceil(3).saturating_mul(4));
1378 let next_uri = budget.data_uri_bytes.saturating_add(uri_bytes);
1379 if next_uri > MAX_ODT_TOTAL_DATA_URI_BYTES {
1380 push_odt_warning_once(
1381 warnings,
1382 "ODT inline images exceeded the total data URI limit",
1383 );
1384 return Ok(());
1385 }
1386 blocks.push(HtmlBlock::Image {
1387 href: format!("{prefix}{}", BASE64_STANDARD.encode(&bytes)),
1388 pixel_width: width,
1389 pixel_height: height,
1390 alt: alt.to_owned(),
1391 });
1392 budget.decoded_bytes = next_bytes;
1393 budget.data_uri_bytes = next_uri;
1394 budget.pixels = next_pixels;
1395 Ok(())
1396}
1397
1398fn reserve_odt_image(budget: &mut OdtImageBudget, warnings: &mut Vec<String>) -> bool {
1399 if budget.count >= MAX_ODT_IMAGES {
1400 push_odt_warning_once(
1401 warnings,
1402 "ODT image count exceeded the supported limit; remaining images were omitted",
1403 );
1404 false
1405 } else {
1406 budget.count += 1;
1407 true
1408 }
1409}
1410
1411fn push_odt_warning_once(warnings: &mut Vec<String>, warning: &str) {
1412 if !warnings.iter().any(|existing| existing == warning) {
1413 warnings.push(warning.to_owned());
1414 }
1415}
1416
1417fn parse_repeat(element: &BytesStart<'_>, name: &[u8]) -> Result<usize> {
1418 let count = attribute(element, name)
1419 .map(|value| {
1420 value.parse::<usize>().map_err(|_| {
1421 Error::InvalidInput(format!(
1422 "invalid OpenDocument repeat count for '{}'",
1423 String::from_utf8_lossy(name)
1424 ))
1425 })
1426 })
1427 .transpose()?
1428 .unwrap_or(1);
1429 if count == 0 || count > MAX_ODT_REPEAT {
1430 return Err(Error::LimitExceeded(format!(
1431 "OpenDocument repeat count must be between 1 and {MAX_ODT_REPEAT}"
1432 )));
1433 }
1434 Ok(count)
1435}
1436
1437fn append_spaces(
1438 paragraph: &mut Option<Paragraph>,
1439 cell: &mut Option<String>,
1440 element: &BytesStart<'_>,
1441 text_bytes: &mut usize,
1442 text_run_count: &mut usize,
1443) -> Result<()> {
1444 let count = parse_repeat(element, b"c")?;
1445 let spaces = " ".repeat(count);
1446 append_text(paragraph, cell, &spaces, text_bytes, text_run_count)?;
1447 Ok(())
1448}
1449
1450fn new_paragraph(kind: ParagraphKind, style: HtmlTextStyle) -> Paragraph {
1451 Paragraph {
1452 kind,
1453 base_style: style.clone(),
1454 current_style: style,
1455 runs: Vec::new(),
1456 }
1457}
1458
1459fn append_text(
1460 paragraph: &mut Option<Paragraph>,
1461 cell: &mut Option<String>,
1462 text: &str,
1463 text_bytes: &mut usize,
1464 text_run_count: &mut usize,
1465) -> Result<()> {
1466 if text.is_empty() {
1467 return Ok(());
1468 }
1469 *text_bytes = text_bytes.saturating_add(text.len());
1470 if *text_bytes > MAX_ODT_TEXT_BYTES {
1471 return Err(Error::LimitExceeded(format!(
1472 "ODT rendered text exceeds {MAX_ODT_TEXT_BYTES} bytes"
1473 )));
1474 }
1475 if let Some(paragraph) = paragraph.as_mut() {
1476 if let Some(last) = paragraph.runs.last_mut()
1477 && last.style == paragraph.current_style
1478 {
1479 last.text.push_str(text);
1480 } else {
1481 *text_run_count = text_run_count.saturating_add(1);
1482 if *text_run_count > MAX_ODT_TEXT_RUNS {
1483 return Err(Error::LimitExceeded(format!(
1484 "ODT text exceeds {MAX_ODT_TEXT_RUNS} style runs"
1485 )));
1486 }
1487 paragraph.runs.push(HtmlTextRun {
1488 text: text.to_owned(),
1489 style: paragraph.current_style.clone(),
1490 });
1491 }
1492 } else if let Some(cell) = cell.as_mut() {
1493 cell.push_str(text);
1494 }
1495 Ok(())
1496}
1497
1498fn flush_paragraph(
1499 paragraph: &mut Option<Paragraph>,
1500 cell: &mut Option<String>,
1501 blocks: &mut Vec<HtmlBlock>,
1502 warnings: &mut Vec<String>,
1503 warned_table_styles: &mut bool,
1504) {
1505 let Some(mut paragraph) = paragraph.take() else {
1506 return;
1507 };
1508 trim_odt_runs(&mut paragraph.runs);
1509 let text = paragraph
1510 .runs
1511 .iter()
1512 .map(|run| run.text.as_str())
1513 .collect::<String>();
1514 if text.is_empty() {
1515 return;
1516 }
1517 if let Some(cell) = cell.as_mut() {
1518 if (!odt_style_is_empty(¶graph.base_style)
1519 || paragraph
1520 .runs
1521 .iter()
1522 .any(|run| !odt_style_is_empty(&run.style)))
1523 && !*warned_table_styles
1524 {
1525 warnings
1526 .push("ODT rich text styles inside table cells are flattened to plain text".into());
1527 *warned_table_styles = true;
1528 }
1529 if !cell.is_empty() {
1530 cell.push('\n');
1531 }
1532 cell.push_str(&text);
1533 return;
1534 }
1535 let styled = !odt_style_is_empty(¶graph.base_style)
1536 || paragraph
1537 .runs
1538 .iter()
1539 .any(|run| !odt_style_is_empty(&run.style));
1540 if styled {
1541 let kind = match paragraph.kind {
1542 ParagraphKind::Heading(level) => HtmlTextBlockKind::Heading(level),
1543 ParagraphKind::Paragraph => HtmlTextBlockKind::Paragraph,
1544 ParagraphKind::ListItem => HtmlTextBlockKind::ListItem {
1545 bullet: "•".into()
1546 },
1547 };
1548 blocks.push(HtmlBlock::StyledText {
1549 kind,
1550 runs: paragraph.runs,
1551 });
1552 return;
1553 }
1554 blocks.push(match paragraph.kind {
1555 ParagraphKind::Heading(level) => HtmlBlock::Heading { level, text },
1556 ParagraphKind::Paragraph => HtmlBlock::Paragraph { text },
1557 ParagraphKind::ListItem => HtmlBlock::ListItem {
1558 bullet: "•".into(),
1559 text,
1560 },
1561 });
1562}
1563
1564fn trim_odt_runs(runs: &mut Vec<HtmlTextRun>) {
1565 let mut first_nonempty = 0usize;
1566 while first_nonempty < runs.len() {
1567 runs[first_nonempty].text = runs[first_nonempty].text.trim_start().to_owned();
1568 if runs[first_nonempty].text.is_empty() {
1569 first_nonempty += 1;
1570 } else {
1571 break;
1572 }
1573 }
1574 if first_nonempty > 0 {
1575 runs.drain(..first_nonempty);
1576 }
1577 let mut end = runs.len();
1578 while end > 0 {
1579 runs[end - 1].text = runs[end - 1].text.trim_end().to_owned();
1580 if runs[end - 1].text.is_empty() {
1581 end -= 1;
1582 } else {
1583 break;
1584 }
1585 }
1586 runs.truncate(end);
1587}
1588
1589fn odt_style_is_empty(style: &HtmlTextStyle) -> bool {
1590 style.font_family.is_none()
1591 && style.font_size.is_none()
1592 && style.bold.is_none()
1593 && style.italic.is_none()
1594 && style.color.is_none()
1595}
1596
1597fn append_cell(
1598 value: String,
1599 repeats: usize,
1600 row: &mut Option<Vec<String>>,
1601 row_text_bytes: &mut usize,
1602) -> Result<()> {
1603 let row = row
1604 .as_mut()
1605 .ok_or_else(|| Error::InvalidInput("ODT table cell appears outside a table row".into()))?;
1606 if row.len().saturating_add(repeats) > MAX_ODT_TABLE_CELLS {
1607 return Err(Error::LimitExceeded(format!(
1608 "ODT table row exceeds {MAX_ODT_TABLE_CELLS} cells"
1609 )));
1610 }
1611 let added_text_bytes = value
1612 .len()
1613 .checked_mul(repeats)
1614 .ok_or_else(|| Error::LimitExceeded("ODT table text size overflowed".into()))?;
1615 *row_text_bytes = row_text_bytes
1616 .checked_add(added_text_bytes)
1617 .ok_or_else(|| Error::LimitExceeded("ODT table text size overflowed".into()))?;
1618 if *row_text_bytes > MAX_ODT_EXPANDED_TABLE_TEXT_BYTES {
1619 return Err(Error::LimitExceeded(format!(
1620 "ODT expanded table text exceeds {MAX_ODT_EXPANDED_TABLE_TEXT_BYTES} bytes"
1621 )));
1622 }
1623 row.extend(std::iter::repeat_n(value, repeats));
1624 Ok(())
1625}
1626
1627fn append_row(
1628 values: Vec<String>,
1629 row_text_bytes: usize,
1630 repeats: usize,
1631 is_header: bool,
1632 table: &mut Option<TableBuilder>,
1633) -> Result<()> {
1634 let table = table
1635 .as_mut()
1636 .ok_or_else(|| Error::InvalidInput("ODT table row appears outside a table".into()))?;
1637 let total_rows = table.rows.len() + usize::from(!table.headers.is_empty());
1638 if total_rows.saturating_add(repeats) > MAX_ODT_TABLE_CELLS {
1639 return Err(Error::LimitExceeded(format!(
1640 "ODT table exceeds {MAX_ODT_TABLE_CELLS} rows"
1641 )));
1642 }
1643 let added_cells = values
1644 .len()
1645 .checked_mul(repeats)
1646 .ok_or_else(|| Error::LimitExceeded("ODT table cell count overflowed".into()))?;
1647 table.total_cells = table
1648 .total_cells
1649 .checked_add(added_cells)
1650 .ok_or_else(|| Error::LimitExceeded("ODT table cell count overflowed".into()))?;
1651 if table.total_cells > MAX_ODT_TABLE_CELLS {
1652 return Err(Error::LimitExceeded(format!(
1653 "ODT table exceeds {MAX_ODT_TABLE_CELLS} cells"
1654 )));
1655 }
1656 let added_text_bytes = row_text_bytes
1657 .checked_mul(repeats)
1658 .ok_or_else(|| Error::LimitExceeded("ODT expanded table text size overflowed".into()))?;
1659 table.total_text_bytes = table
1660 .total_text_bytes
1661 .checked_add(added_text_bytes)
1662 .ok_or_else(|| Error::LimitExceeded("ODT expanded table text size overflowed".into()))?;
1663 if table.total_text_bytes > MAX_ODT_EXPANDED_TABLE_TEXT_BYTES {
1664 return Err(Error::LimitExceeded(format!(
1665 "ODT expanded table text exceeds {MAX_ODT_EXPANDED_TABLE_TEXT_BYTES} bytes"
1666 )));
1667 }
1668 for _ in 0..repeats {
1669 if is_header && table.headers.is_empty() {
1670 table.headers = values.clone();
1671 } else {
1672 table.rows.push(values.clone());
1673 }
1674 }
1675 Ok(())
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680 use super::*;
1681
1682 #[test]
1683 fn rejects_document_type_declarations_and_limits_xml_depth() {
1684 let doctype = r#"<!DOCTYPE office:document-content [<!ENTITY x "expanded">]><office:document-content/>"#;
1685 assert!(matches!(
1686 parse_content(doctype, 100, None),
1687 Err(Error::InvalidInput(_))
1688 ));
1689
1690 let nested = format!("{}<office:text/>{}", "<x>".repeat(300), "</x>".repeat(300));
1691 assert!(matches!(
1692 parse_content(&nested, 2_000, None),
1693 Err(Error::LimitExceeded(_))
1694 ));
1695 }
1696
1697 #[test]
1698 fn repeated_cells_cannot_expand_past_the_table_budget() {
1699 let xml = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"><office:body><office:text><table:table><table:table-row><table:table-cell table:number-columns-repeated="10001"/></table:table-row></table:table></office:text></office:body></office:document-content>"#;
1700 assert!(matches!(
1701 parse_content(xml, 1_000, None),
1702 Err(Error::LimitExceeded(_))
1703 ));
1704 }
1705
1706 #[test]
1707 fn repeated_rows_cannot_amplify_large_text_into_unbounded_memory() {
1708 let content = "T".repeat(40_000);
1709 let xml = format!(
1710 "<office:document-content xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\"><office:body><office:text><table:table><table:table-row table:number-rows-repeated=\"1000\"><table:table-cell><text:p>{content}</text:p></table:table-cell></table:table-row></table:table></office:text></office:body></office:document-content>"
1711 );
1712 let error = parse_content(&xml, 10_000, None).unwrap_err();
1713 assert!(error.to_string().contains("expanded table text"));
1714 }
1715
1716 #[test]
1717 fn inline_binary_image_payload_is_embedded_without_becoming_text() {
1718 let xml = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:xlink="http://www.w3.org/1999/xlink"><office:body><office:text><text:p>before<draw:frame draw:name="inline"><draw:image xlink:href="Pictures/fallback.png"><office:binary-data>iVBORw0KGgoAAAANSUhEUgAAACAAAAAQCAIAAAD4YuoOAAAAIklEQVR4nGP4z8BAEiJR+X9SlY9aMGrBqAWjFoxaMCAWAABQpv4QX+h4RQAAAABJRU5ErkJggg==</office:binary-data></draw:image></draw:frame>after</text:p></office:text></office:body></office:document-content>"#;
1719 let (blocks, warnings) = parse_content(xml, 1_000, None).unwrap();
1720 let rendered_text = format!("{blocks:?}");
1721
1722 assert!(rendered_text.contains("before"));
1723 assert!(rendered_text.contains("after"));
1724 assert!(rendered_text.contains("data:image/png;base64,"));
1725 assert!(
1726 blocks
1727 .iter()
1728 .any(|block| matches!(block, HtmlBlock::Image { .. }))
1729 );
1730 assert!(
1731 !warnings
1732 .iter()
1733 .any(|warning| warning.contains("binary-data"))
1734 );
1735 }
1736}